From fda0a19ecd4f3428ad94c8a46232e2cd4272094d Mon Sep 17 00:00:00 2001 From: sebi Date: Tue, 28 Apr 2026 19:43:28 -0500 Subject: [PATCH 01/10] csharp: implement full v1+v2 surface (0.2.0) 35 endpoints (6 v1 + 29 v2) wired against the Rails-controller-derived spec. Adds resource clients on V1Client/V2Client (Accounts, Transactions, Status, plus v2 SyncSessions, TransactionOrders, Batches, PaymentMethods), 20 typed exception classes covering every error_code, models as records with snake_case wire markers, and Transport.RequestRawAsync for CSV/JSON export. dotnet build clean (TreatWarningsAsErrors), 70/70 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/csharp/CHANGELOG.md | 36 +++ .../src/Errors/AccountNotFoundException.cs | 18 ++ .../Errors/BankConnectionNotFoundException.cs | 18 ++ .../src/Errors/BankSubmissionException.cs | 18 ++ .../Errors/BankUnderMaintenanceException.cs | 18 ++ .../src/Errors/BatchNotFoundException.cs | 18 ++ .../src/Errors/BatchValidationException.cs | 18 ++ packages/csharp/src/Errors/ErrorDispatcher.cs | 60 ++++ .../src/Errors/InternalServerException.cs | 18 ++ .../src/Errors/InvalidCountException.cs | 18 ++ .../src/Errors/InvalidCursorException.cs | 18 ++ .../src/Errors/InvalidLimitException.cs | 18 ++ .../src/Errors/InvalidOrderStateException.cs | 18 ++ .../src/Errors/InvalidQueryException.cs | 18 ++ .../src/Errors/MissingDateRangeException.cs | 18 ++ .../csharp/src/Errors/NotFoundException.cs | 18 ++ .../Errors/PaymentMethodNotFoundException.cs | 18 ++ .../src/Errors/SyncInProgressException.cs | 18 ++ .../Errors/SyncRateLimitExceededException.cs | 18 ++ .../Errors/SyncSessionNotFoundException.cs | 18 ++ .../Errors/TransactionNotFoundException.cs | 18 ++ .../TransactionOrderNotFoundException.cs | 18 ++ .../csharp/src/Errors/ValidationException.cs | 18 ++ packages/csharp/src/Internal/Json.cs | 24 +- packages/csharp/src/Internal/QueryBuilder.cs | 66 +++++ packages/csharp/src/Internal/Requests.cs | 36 +++ packages/csharp/src/Models/Account.cs | 46 +++ packages/csharp/src/Models/Bulk.cs | 19 ++ packages/csharp/src/Models/PaymentMethod.cs | 66 +++++ packages/csharp/src/Models/Status.cs | 18 ++ packages/csharp/src/Models/SyncSession.cs | 40 +++ packages/csharp/src/Models/SyncTransaction.cs | 32 +++ packages/csharp/src/Models/Transaction.cs | 50 ++++ .../csharp/src/Models/TransactionOrder.cs | 129 +++++++++ packages/csharp/src/RawResponse.cs | 17 ++ packages/csharp/src/Tesote.Sdk.csproj | 2 +- packages/csharp/src/Transport.cs | 133 ++++++++- packages/csharp/src/V1/AccountsClient.cs | 63 +++- packages/csharp/src/V1/StatusClient.cs | 31 ++ packages/csharp/src/V1/TransactionsClient.cs | 30 ++ packages/csharp/src/V1/V1Client.cs | 18 +- packages/csharp/src/V2/AccountsClient.cs | 46 ++- packages/csharp/src/V2/BatchesClient.cs | 130 +++++++++ .../csharp/src/V2/PaymentMethodsClient.cs | 143 ++++++++++ packages/csharp/src/V2/StatusClient.cs | 31 ++ packages/csharp/src/V2/SyncSessionsClient.cs | 51 ++++ .../csharp/src/V2/TransactionOrdersClient.cs | 140 +++++++++ packages/csharp/src/V2/TransactionsClient.cs | 268 ++++++++++++++++++ packages/csharp/src/V2/V2Client.cs | 48 ++-- packages/csharp/tests/AccountsTests.cs | 120 ++++++++ packages/csharp/tests/BatchesTests.cs | 134 +++++++++ packages/csharp/tests/PaymentMethodsTests.cs | 133 +++++++++ packages/csharp/tests/StatusTests.cs | 55 ++++ packages/csharp/tests/SyncSessionsTests.cs | 60 ++++ packages/csharp/tests/TestHelpers.cs | 31 ++ .../csharp/tests/TransactionOrdersTests.cs | 131 +++++++++ packages/csharp/tests/TransactionsTests.cs | 213 ++++++++++++++ 57 files changed, 2976 insertions(+), 52 deletions(-) create mode 100644 packages/csharp/src/Errors/AccountNotFoundException.cs create mode 100644 packages/csharp/src/Errors/BankConnectionNotFoundException.cs create mode 100644 packages/csharp/src/Errors/BankSubmissionException.cs create mode 100644 packages/csharp/src/Errors/BankUnderMaintenanceException.cs create mode 100644 packages/csharp/src/Errors/BatchNotFoundException.cs create mode 100644 packages/csharp/src/Errors/BatchValidationException.cs create mode 100644 packages/csharp/src/Errors/InternalServerException.cs create mode 100644 packages/csharp/src/Errors/InvalidCountException.cs create mode 100644 packages/csharp/src/Errors/InvalidCursorException.cs create mode 100644 packages/csharp/src/Errors/InvalidLimitException.cs create mode 100644 packages/csharp/src/Errors/InvalidOrderStateException.cs create mode 100644 packages/csharp/src/Errors/InvalidQueryException.cs create mode 100644 packages/csharp/src/Errors/MissingDateRangeException.cs create mode 100644 packages/csharp/src/Errors/NotFoundException.cs create mode 100644 packages/csharp/src/Errors/PaymentMethodNotFoundException.cs create mode 100644 packages/csharp/src/Errors/SyncInProgressException.cs create mode 100644 packages/csharp/src/Errors/SyncRateLimitExceededException.cs create mode 100644 packages/csharp/src/Errors/SyncSessionNotFoundException.cs create mode 100644 packages/csharp/src/Errors/TransactionNotFoundException.cs create mode 100644 packages/csharp/src/Errors/TransactionOrderNotFoundException.cs create mode 100644 packages/csharp/src/Errors/ValidationException.cs create mode 100644 packages/csharp/src/Internal/QueryBuilder.cs create mode 100644 packages/csharp/src/Internal/Requests.cs create mode 100644 packages/csharp/src/Models/Account.cs create mode 100644 packages/csharp/src/Models/Bulk.cs create mode 100644 packages/csharp/src/Models/PaymentMethod.cs create mode 100644 packages/csharp/src/Models/Status.cs create mode 100644 packages/csharp/src/Models/SyncSession.cs create mode 100644 packages/csharp/src/Models/SyncTransaction.cs create mode 100644 packages/csharp/src/Models/Transaction.cs create mode 100644 packages/csharp/src/Models/TransactionOrder.cs create mode 100644 packages/csharp/src/RawResponse.cs create mode 100644 packages/csharp/src/V1/StatusClient.cs create mode 100644 packages/csharp/src/V1/TransactionsClient.cs create mode 100644 packages/csharp/src/V2/BatchesClient.cs create mode 100644 packages/csharp/src/V2/PaymentMethodsClient.cs create mode 100644 packages/csharp/src/V2/StatusClient.cs create mode 100644 packages/csharp/src/V2/SyncSessionsClient.cs create mode 100644 packages/csharp/src/V2/TransactionOrdersClient.cs create mode 100644 packages/csharp/src/V2/TransactionsClient.cs create mode 100644 packages/csharp/tests/AccountsTests.cs create mode 100644 packages/csharp/tests/BatchesTests.cs create mode 100644 packages/csharp/tests/PaymentMethodsTests.cs create mode 100644 packages/csharp/tests/StatusTests.cs create mode 100644 packages/csharp/tests/SyncSessionsTests.cs create mode 100644 packages/csharp/tests/TestHelpers.cs create mode 100644 packages/csharp/tests/TransactionOrdersTests.cs create mode 100644 packages/csharp/tests/TransactionsTests.cs diff --git a/packages/csharp/CHANGELOG.md b/packages/csharp/CHANGELOG.md index 57dae37..0229363 100644 --- a/packages/csharp/CHANGELOG.md +++ b/packages/csharp/CHANGELOG.md @@ -4,6 +4,42 @@ All notable changes to the `Tesote.Sdk` NuGet package are documented here. This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 0.2.0 - 2026-04-28 + +Full v1 + v2 resource surface. + +### Added + +- All 35 documented v1 + v2 endpoints implemented, with typed model records + in `Tesote.Sdk.Models` (Account, Transaction, SyncTransaction, SyncSession, + TransactionOrder, PaymentMethod, BatchSummary, etc.) — `JsonPropertyName` + attributes preserve snake_case on the wire. +- Resource clients: `V1.{StatusClient, AccountsClient, TransactionsClient}` + and `V2.{StatusClient, AccountsClient, TransactionsClient, + SyncSessionsClient, TransactionOrdersClient, BatchesClient, + PaymentMethodsClient}` — accessible as properties on `V1Client` / `V2Client`. +- New typed exceptions for every documented `error_code`: + `AccountNotFoundException`, `TransactionNotFoundException`, + `SyncSessionNotFoundException`, `PaymentMethodNotFoundException`, + `TransactionOrderNotFoundException`, `BatchNotFoundException`, + `BankConnectionNotFoundException`, `InvalidCursorException`, + `InvalidCountException`, `InvalidLimitException`, `InvalidQueryException`, + `MissingDateRangeException`, `SyncInProgressException`, + `SyncRateLimitExceededException`, `BankUnderMaintenanceException`, + `ValidationException`, `BatchValidationException`, + `InvalidOrderStateException`, `BankSubmissionException`, + `InternalServerException`, plus a shared `NotFoundException` base. +- `Transport.RequestRawAsync` for file-download endpoints (CSV / JSON export); + preserves retries, rate-limit awareness, idempotency, and error mapping. +- xUnit + WireMock.Net coverage per resource: success, typed-error mapping, + pagination, idempotency, and the 415 case. + +### Changed + +- `Internal.Json.DefaultOptions` no longer applies a global + `PropertyNamingPolicy`; per-property `[JsonPropertyName]` markers keep the + wire format stable and allow PascalCase model surfaces. + ## 0.1.0 - 2026-04-28 Initial bootstrap. diff --git a/packages/csharp/src/Errors/AccountNotFoundException.cs b/packages/csharp/src/Errors/AccountNotFoundException.cs new file mode 100644 index 0000000..76179cf --- /dev/null +++ b/packages/csharp/src/Errors/AccountNotFoundException.cs @@ -0,0 +1,18 @@ +using System; + +namespace Tesote.Sdk.Errors; + +/// 404 ACCOUNT_NOT_FOUND. +public sealed class AccountNotFoundException : NotFoundException +{ + /// Construct with the full required-field set. + public AccountNotFoundException( + string? message, string? errorCode, int httpStatus, + string? requestId, string? errorId, int? retryAfter, + string? responseBody, RequestSummary? requestSummary, + int attempts, Exception? cause) + : base(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause) + { + } +} diff --git a/packages/csharp/src/Errors/BankConnectionNotFoundException.cs b/packages/csharp/src/Errors/BankConnectionNotFoundException.cs new file mode 100644 index 0000000..35f6e08 --- /dev/null +++ b/packages/csharp/src/Errors/BankConnectionNotFoundException.cs @@ -0,0 +1,18 @@ +using System; + +namespace Tesote.Sdk.Errors; + +/// 404 BANK_CONNECTION_NOT_FOUND. +public sealed class BankConnectionNotFoundException : NotFoundException +{ + /// Construct with the full required-field set. + public BankConnectionNotFoundException( + string? message, string? errorCode, int httpStatus, + string? requestId, string? errorId, int? retryAfter, + string? responseBody, RequestSummary? requestSummary, + int attempts, Exception? cause) + : base(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause) + { + } +} diff --git a/packages/csharp/src/Errors/BankSubmissionException.cs b/packages/csharp/src/Errors/BankSubmissionException.cs new file mode 100644 index 0000000..0481a21 --- /dev/null +++ b/packages/csharp/src/Errors/BankSubmissionException.cs @@ -0,0 +1,18 @@ +using System; + +namespace Tesote.Sdk.Errors; + +/// 422 BANK_SUBMISSION_ERROR — upstream bank rejected the order. +public sealed class BankSubmissionException : UnprocessableContentException +{ + /// Construct with the full required-field set. + public BankSubmissionException( + string? message, string? errorCode, int httpStatus, + string? requestId, string? errorId, int? retryAfter, + string? responseBody, RequestSummary? requestSummary, + int attempts, Exception? cause) + : base(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause) + { + } +} diff --git a/packages/csharp/src/Errors/BankUnderMaintenanceException.cs b/packages/csharp/src/Errors/BankUnderMaintenanceException.cs new file mode 100644 index 0000000..a5561d2 --- /dev/null +++ b/packages/csharp/src/Errors/BankUnderMaintenanceException.cs @@ -0,0 +1,18 @@ +using System; + +namespace Tesote.Sdk.Errors; + +/// 503 BANK_UNDER_MAINTENANCE — upstream bank is unavailable. +public sealed class BankUnderMaintenanceException : ApiException +{ + /// Construct with the full required-field set. + public BankUnderMaintenanceException( + string? message, string? errorCode, int httpStatus, + string? requestId, string? errorId, int? retryAfter, + string? responseBody, RequestSummary? requestSummary, + int attempts, Exception? cause) + : base(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause) + { + } +} diff --git a/packages/csharp/src/Errors/BatchNotFoundException.cs b/packages/csharp/src/Errors/BatchNotFoundException.cs new file mode 100644 index 0000000..87bdc51 --- /dev/null +++ b/packages/csharp/src/Errors/BatchNotFoundException.cs @@ -0,0 +1,18 @@ +using System; + +namespace Tesote.Sdk.Errors; + +/// 404 BATCH_NOT_FOUND. +public sealed class BatchNotFoundException : NotFoundException +{ + /// Construct with the full required-field set. + public BatchNotFoundException( + string? message, string? errorCode, int httpStatus, + string? requestId, string? errorId, int? retryAfter, + string? responseBody, RequestSummary? requestSummary, + int attempts, Exception? cause) + : base(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause) + { + } +} diff --git a/packages/csharp/src/Errors/BatchValidationException.cs b/packages/csharp/src/Errors/BatchValidationException.cs new file mode 100644 index 0000000..c989184 --- /dev/null +++ b/packages/csharp/src/Errors/BatchValidationException.cs @@ -0,0 +1,18 @@ +using System; + +namespace Tesote.Sdk.Errors; + +/// 400 BATCH_VALIDATION_ERROR — batch payload failed validation. +public sealed class BatchValidationException : ValidationException +{ + /// Construct with the full required-field set. + public BatchValidationException( + string? message, string? errorCode, int httpStatus, + string? requestId, string? errorId, int? retryAfter, + string? responseBody, RequestSummary? requestSummary, + int attempts, Exception? cause) + : base(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause) + { + } +} diff --git a/packages/csharp/src/Errors/ErrorDispatcher.cs b/packages/csharp/src/Errors/ErrorDispatcher.cs index 10f6fa6..9840b5f 100644 --- a/packages/csharp/src/Errors/ErrorDispatcher.cs +++ b/packages/csharp/src/Errors/ErrorDispatcher.cs @@ -46,12 +46,72 @@ public static ApiException Dispatch( "INVALID_DATE_RANGE" => new InvalidDateRangeException( message, code, httpStatus, requestId, errorId, retryAfter, responseBody, requestSummary, attempts, cause), + "INVALID_CURSOR" => new InvalidCursorException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause), + "INVALID_COUNT" => new InvalidCountException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause), + "INVALID_LIMIT" => new InvalidLimitException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause), + "INVALID_QUERY" => new InvalidQueryException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause), + "MISSING_DATE_RANGE" => new MissingDateRangeException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause), "UNPROCESSABLE_CONTENT" => new UnprocessableContentException( message, code, httpStatus, requestId, errorId, retryAfter, responseBody, requestSummary, attempts, cause), + "BANK_SUBMISSION_ERROR" => new BankSubmissionException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause), "RATE_LIMIT_EXCEEDED" => new RateLimitExceededException( message, code, httpStatus, requestId, errorId, retryAfter, responseBody, requestSummary, attempts, cause), + "SYNC_RATE_LIMIT_EXCEEDED" => new SyncRateLimitExceededException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause), + "ACCOUNT_NOT_FOUND" => new AccountNotFoundException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause), + "TRANSACTION_NOT_FOUND" => new TransactionNotFoundException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause), + "SYNC_SESSION_NOT_FOUND" => new SyncSessionNotFoundException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause), + "PAYMENT_METHOD_NOT_FOUND" => new PaymentMethodNotFoundException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause), + "TRANSACTION_ORDER_NOT_FOUND" => new TransactionOrderNotFoundException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause), + "BATCH_NOT_FOUND" => new BatchNotFoundException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause), + "BANK_CONNECTION_NOT_FOUND" => new BankConnectionNotFoundException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause), + "SYNC_IN_PROGRESS" => new SyncInProgressException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause), + "INVALID_ORDER_STATE" => new InvalidOrderStateException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause), + "BATCH_VALIDATION_ERROR" => new BatchValidationException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause), + "VALIDATION_ERROR" => new ValidationException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause), + "BANK_UNDER_MAINTENANCE" => new BankUnderMaintenanceException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause), + "INTERNAL_ERROR" => new InternalServerException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause), // why: 503 with no envelope code is the documented "pause mode" signal — // surface it as ServiceUnavailableException so callers can dispatch on type. _ when httpStatus == 503 => new ServiceUnavailableException( diff --git a/packages/csharp/src/Errors/InternalServerException.cs b/packages/csharp/src/Errors/InternalServerException.cs new file mode 100644 index 0000000..18884dc --- /dev/null +++ b/packages/csharp/src/Errors/InternalServerException.cs @@ -0,0 +1,18 @@ +using System; + +namespace Tesote.Sdk.Errors; + +/// 500 INTERNAL_ERROR — server-side failure with an error_id for support. +public sealed class InternalServerException : ApiException +{ + /// Construct with the full required-field set. + public InternalServerException( + string? message, string? errorCode, int httpStatus, + string? requestId, string? errorId, int? retryAfter, + string? responseBody, RequestSummary? requestSummary, + int attempts, Exception? cause) + : base(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause) + { + } +} diff --git a/packages/csharp/src/Errors/InvalidCountException.cs b/packages/csharp/src/Errors/InvalidCountException.cs new file mode 100644 index 0000000..2543f6e --- /dev/null +++ b/packages/csharp/src/Errors/InvalidCountException.cs @@ -0,0 +1,18 @@ +using System; + +namespace Tesote.Sdk.Errors; + +/// 422 INVALID_COUNT. +public sealed class InvalidCountException : UnprocessableContentException +{ + /// Construct with the full required-field set. + public InvalidCountException( + string? message, string? errorCode, int httpStatus, + string? requestId, string? errorId, int? retryAfter, + string? responseBody, RequestSummary? requestSummary, + int attempts, Exception? cause) + : base(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause) + { + } +} diff --git a/packages/csharp/src/Errors/InvalidCursorException.cs b/packages/csharp/src/Errors/InvalidCursorException.cs new file mode 100644 index 0000000..ac93087 --- /dev/null +++ b/packages/csharp/src/Errors/InvalidCursorException.cs @@ -0,0 +1,18 @@ +using System; + +namespace Tesote.Sdk.Errors; + +/// 422 INVALID_CURSOR. +public sealed class InvalidCursorException : UnprocessableContentException +{ + /// Construct with the full required-field set. + public InvalidCursorException( + string? message, string? errorCode, int httpStatus, + string? requestId, string? errorId, int? retryAfter, + string? responseBody, RequestSummary? requestSummary, + int attempts, Exception? cause) + : base(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause) + { + } +} diff --git a/packages/csharp/src/Errors/InvalidLimitException.cs b/packages/csharp/src/Errors/InvalidLimitException.cs new file mode 100644 index 0000000..0745bc5 --- /dev/null +++ b/packages/csharp/src/Errors/InvalidLimitException.cs @@ -0,0 +1,18 @@ +using System; + +namespace Tesote.Sdk.Errors; + +/// 422 INVALID_LIMIT. +public sealed class InvalidLimitException : UnprocessableContentException +{ + /// Construct with the full required-field set. + public InvalidLimitException( + string? message, string? errorCode, int httpStatus, + string? requestId, string? errorId, int? retryAfter, + string? responseBody, RequestSummary? requestSummary, + int attempts, Exception? cause) + : base(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause) + { + } +} diff --git a/packages/csharp/src/Errors/InvalidOrderStateException.cs b/packages/csharp/src/Errors/InvalidOrderStateException.cs new file mode 100644 index 0000000..202d0f1 --- /dev/null +++ b/packages/csharp/src/Errors/InvalidOrderStateException.cs @@ -0,0 +1,18 @@ +using System; + +namespace Tesote.Sdk.Errors; + +/// 409 INVALID_ORDER_STATE — order cannot transition from current state. +public sealed class InvalidOrderStateException : ApiException +{ + /// Construct with the full required-field set. + public InvalidOrderStateException( + string? message, string? errorCode, int httpStatus, + string? requestId, string? errorId, int? retryAfter, + string? responseBody, RequestSummary? requestSummary, + int attempts, Exception? cause) + : base(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause) + { + } +} diff --git a/packages/csharp/src/Errors/InvalidQueryException.cs b/packages/csharp/src/Errors/InvalidQueryException.cs new file mode 100644 index 0000000..c3f974f --- /dev/null +++ b/packages/csharp/src/Errors/InvalidQueryException.cs @@ -0,0 +1,18 @@ +using System; + +namespace Tesote.Sdk.Errors; + +/// 422 INVALID_QUERY. +public sealed class InvalidQueryException : UnprocessableContentException +{ + /// Construct with the full required-field set. + public InvalidQueryException( + string? message, string? errorCode, int httpStatus, + string? requestId, string? errorId, int? retryAfter, + string? responseBody, RequestSummary? requestSummary, + int attempts, Exception? cause) + : base(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause) + { + } +} diff --git a/packages/csharp/src/Errors/MissingDateRangeException.cs b/packages/csharp/src/Errors/MissingDateRangeException.cs new file mode 100644 index 0000000..fee88be --- /dev/null +++ b/packages/csharp/src/Errors/MissingDateRangeException.cs @@ -0,0 +1,18 @@ +using System; + +namespace Tesote.Sdk.Errors; + +/// 422 MISSING_DATE_RANGE. +public sealed class MissingDateRangeException : UnprocessableContentException +{ + /// Construct with the full required-field set. + public MissingDateRangeException( + string? message, string? errorCode, int httpStatus, + string? requestId, string? errorId, int? retryAfter, + string? responseBody, RequestSummary? requestSummary, + int attempts, Exception? cause) + : base(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause) + { + } +} diff --git a/packages/csharp/src/Errors/NotFoundException.cs b/packages/csharp/src/Errors/NotFoundException.cs new file mode 100644 index 0000000..7a7641b --- /dev/null +++ b/packages/csharp/src/Errors/NotFoundException.cs @@ -0,0 +1,18 @@ +using System; + +namespace Tesote.Sdk.Errors; + +/// 404 family — resource not found. Concrete subclasses identify which resource. +public class NotFoundException : ApiException +{ + /// Construct with the full required-field set. + public NotFoundException( + string? message, string? errorCode, int httpStatus, + string? requestId, string? errorId, int? retryAfter, + string? responseBody, RequestSummary? requestSummary, + int attempts, Exception? cause) + : base(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause) + { + } +} diff --git a/packages/csharp/src/Errors/PaymentMethodNotFoundException.cs b/packages/csharp/src/Errors/PaymentMethodNotFoundException.cs new file mode 100644 index 0000000..10d26e5 --- /dev/null +++ b/packages/csharp/src/Errors/PaymentMethodNotFoundException.cs @@ -0,0 +1,18 @@ +using System; + +namespace Tesote.Sdk.Errors; + +/// 404 PAYMENT_METHOD_NOT_FOUND. +public sealed class PaymentMethodNotFoundException : NotFoundException +{ + /// Construct with the full required-field set. + public PaymentMethodNotFoundException( + string? message, string? errorCode, int httpStatus, + string? requestId, string? errorId, int? retryAfter, + string? responseBody, RequestSummary? requestSummary, + int attempts, Exception? cause) + : base(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause) + { + } +} diff --git a/packages/csharp/src/Errors/SyncInProgressException.cs b/packages/csharp/src/Errors/SyncInProgressException.cs new file mode 100644 index 0000000..700975e --- /dev/null +++ b/packages/csharp/src/Errors/SyncInProgressException.cs @@ -0,0 +1,18 @@ +using System; + +namespace Tesote.Sdk.Errors; + +/// 409 SYNC_IN_PROGRESS — another sync session is currently active. +public sealed class SyncInProgressException : ApiException +{ + /// Construct with the full required-field set. + public SyncInProgressException( + string? message, string? errorCode, int httpStatus, + string? requestId, string? errorId, int? retryAfter, + string? responseBody, RequestSummary? requestSummary, + int attempts, Exception? cause) + : base(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause) + { + } +} diff --git a/packages/csharp/src/Errors/SyncRateLimitExceededException.cs b/packages/csharp/src/Errors/SyncRateLimitExceededException.cs new file mode 100644 index 0000000..d0064c7 --- /dev/null +++ b/packages/csharp/src/Errors/SyncRateLimitExceededException.cs @@ -0,0 +1,18 @@ +using System; + +namespace Tesote.Sdk.Errors; + +/// 429 SYNC_RATE_LIMIT_EXCEEDED — bank-connection sync rate limit hit. +public sealed class SyncRateLimitExceededException : ApiException +{ + /// Construct with the full required-field set. + public SyncRateLimitExceededException( + string? message, string? errorCode, int httpStatus, + string? requestId, string? errorId, int? retryAfter, + string? responseBody, RequestSummary? requestSummary, + int attempts, Exception? cause) + : base(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause) + { + } +} diff --git a/packages/csharp/src/Errors/SyncSessionNotFoundException.cs b/packages/csharp/src/Errors/SyncSessionNotFoundException.cs new file mode 100644 index 0000000..780bea6 --- /dev/null +++ b/packages/csharp/src/Errors/SyncSessionNotFoundException.cs @@ -0,0 +1,18 @@ +using System; + +namespace Tesote.Sdk.Errors; + +/// 404 SYNC_SESSION_NOT_FOUND. +public sealed class SyncSessionNotFoundException : NotFoundException +{ + /// Construct with the full required-field set. + public SyncSessionNotFoundException( + string? message, string? errorCode, int httpStatus, + string? requestId, string? errorId, int? retryAfter, + string? responseBody, RequestSummary? requestSummary, + int attempts, Exception? cause) + : base(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause) + { + } +} diff --git a/packages/csharp/src/Errors/TransactionNotFoundException.cs b/packages/csharp/src/Errors/TransactionNotFoundException.cs new file mode 100644 index 0000000..e78eb0e --- /dev/null +++ b/packages/csharp/src/Errors/TransactionNotFoundException.cs @@ -0,0 +1,18 @@ +using System; + +namespace Tesote.Sdk.Errors; + +/// 404 TRANSACTION_NOT_FOUND. +public sealed class TransactionNotFoundException : NotFoundException +{ + /// Construct with the full required-field set. + public TransactionNotFoundException( + string? message, string? errorCode, int httpStatus, + string? requestId, string? errorId, int? retryAfter, + string? responseBody, RequestSummary? requestSummary, + int attempts, Exception? cause) + : base(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause) + { + } +} diff --git a/packages/csharp/src/Errors/TransactionOrderNotFoundException.cs b/packages/csharp/src/Errors/TransactionOrderNotFoundException.cs new file mode 100644 index 0000000..c3ca6c3 --- /dev/null +++ b/packages/csharp/src/Errors/TransactionOrderNotFoundException.cs @@ -0,0 +1,18 @@ +using System; + +namespace Tesote.Sdk.Errors; + +/// 404 TRANSACTION_ORDER_NOT_FOUND. +public sealed class TransactionOrderNotFoundException : NotFoundException +{ + /// Construct with the full required-field set. + public TransactionOrderNotFoundException( + string? message, string? errorCode, int httpStatus, + string? requestId, string? errorId, int? retryAfter, + string? responseBody, RequestSummary? requestSummary, + int attempts, Exception? cause) + : base(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause) + { + } +} diff --git a/packages/csharp/src/Errors/ValidationException.cs b/packages/csharp/src/Errors/ValidationException.cs new file mode 100644 index 0000000..fa36408 --- /dev/null +++ b/packages/csharp/src/Errors/ValidationException.cs @@ -0,0 +1,18 @@ +using System; + +namespace Tesote.Sdk.Errors; + +/// 400 VALIDATION_ERROR — request body failed validation. +public class ValidationException : ApiException +{ + /// Construct with the full required-field set. + public ValidationException( + string? message, string? errorCode, int httpStatus, + string? requestId, string? errorId, int? retryAfter, + string? responseBody, RequestSummary? requestSummary, + int attempts, Exception? cause) + : base(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause) + { + } +} diff --git a/packages/csharp/src/Internal/Json.cs b/packages/csharp/src/Internal/Json.cs index 1c1aedd..8f5a951 100644 --- a/packages/csharp/src/Internal/Json.cs +++ b/packages/csharp/src/Internal/Json.cs @@ -1,21 +1,23 @@ +using System; using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization; namespace Tesote.Sdk.Internal; /// /// Thin wrapper around shared . Default -/// options use snake_case property naming so SDK model records can declare -/// PascalCase properties and still match the wire shape. +/// options keep snake_case-on-the-wire by relying on per-property +/// markers in the model records. /// public static class Json { /// Shared default options. Treat as immutable. public static readonly JsonSerializerOptions DefaultOptions = new() { - PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, PropertyNameCaseInsensitive = true, WriteIndented = false, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; /// Parse a UTF-8 byte buffer into a tolerant ; null on parse failure. @@ -40,4 +42,20 @@ public static byte[] SerializeToUtf8Bytes(T value) { return JsonSerializer.SerializeToUtf8Bytes(value, DefaultOptions); } + + /// Deserialize a parsed into a typed model record. + public static T Deserialize(JsonNode? node) + { + if (node is null) + { + throw new InvalidOperationException("cannot deserialize null JSON node"); + } + var raw = node.ToJsonString(DefaultOptions); + var result = JsonSerializer.Deserialize(raw, DefaultOptions); + if (result is null) + { + throw new InvalidOperationException("JSON deserialized to null for " + typeof(T).FullName); + } + return result; + } } diff --git a/packages/csharp/src/Internal/QueryBuilder.cs b/packages/csharp/src/Internal/QueryBuilder.cs new file mode 100644 index 0000000..f00c9a2 --- /dev/null +++ b/packages/csharp/src/Internal/QueryBuilder.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; +using System.Globalization; + +namespace Tesote.Sdk.Internal; + +/// +/// Small helper for building query-parameter dictionaries from typed values. +/// Skips null entries so callers don't pollute the URL with empty params. +/// +internal sealed class QueryBuilder +{ + private readonly Dictionary _values = new(); + + public QueryBuilder Add(string key, string? value) + { + if (value is null) + { + return this; + } + _values[key] = value; + return this; + } + + public QueryBuilder Add(string key, int? value) + { + if (value is null) + { + return this; + } + _values[key] = value.Value.ToString(CultureInfo.InvariantCulture); + return this; + } + + public QueryBuilder Add(string key, long? value) + { + if (value is null) + { + return this; + } + _values[key] = value.Value.ToString(CultureInfo.InvariantCulture); + return this; + } + + public QueryBuilder Add(string key, decimal? value) + { + if (value is null) + { + return this; + } + _values[key] = value.Value.ToString(CultureInfo.InvariantCulture); + return this; + } + + public QueryBuilder Add(string key, bool? value) + { + if (value is null) + { + return this; + } + _values[key] = value.Value ? "true" : "false"; + return this; + } + + public IReadOnlyDictionary? BuildOrNull() + => _values.Count == 0 ? null : _values; +} diff --git a/packages/csharp/src/Internal/Requests.cs b/packages/csharp/src/Internal/Requests.cs new file mode 100644 index 0000000..5042f49 --- /dev/null +++ b/packages/csharp/src/Internal/Requests.cs @@ -0,0 +1,36 @@ +using System; +using System.Text; + +namespace Tesote.Sdk.Internal; + +/// Internal helpers for resource clients to construct . +internal static class Requests +{ + /// Build a JSON-encoded mutating request with idempotency-key support. + public static RequestOptions Json(string method, string path, object? body, string? idempotencyKey = null) + { + var opts = new RequestOptions + { + Method = method, + Path = path, + IdempotencyKey = idempotencyKey, + }; + + var requiresBody = method.Equals("POST", StringComparison.OrdinalIgnoreCase) + || method.Equals("PUT", StringComparison.OrdinalIgnoreCase) + || method.Equals("PATCH", StringComparison.OrdinalIgnoreCase); + + if (body is not null) + { + opts.Body = Internal.Json.SerializeToUtf8Bytes(body); + opts.BodyShape = opts.Body.Length + " bytes"; + } + else if (requiresBody) + { + // why: server requires Content-Type: application/json on every POST/PUT/PATCH. + opts.Body = Encoding.UTF8.GetBytes("{}"); + opts.BodyShape = "0 bytes"; + } + return opts; + } +} diff --git a/packages/csharp/src/Models/Account.cs b/packages/csharp/src/Models/Account.cs new file mode 100644 index 0000000..6aebd7e --- /dev/null +++ b/packages/csharp/src/Models/Account.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Tesote.Sdk.Models; + +/// Bank metadata embedded in an . +public sealed record AccountBank( + [property: JsonPropertyName("name")] string Name); + +/// Legal-entity attribution for an . +public sealed record AccountLegalEntity( + [property: JsonPropertyName("id")] string? Id, + [property: JsonPropertyName("legal_name")] string? LegalName); + +/// Wire-side account-data envelope. Snake_case is preserved. +public sealed record AccountData( + [property: JsonPropertyName("masked_account_number")] string MaskedAccountNumber, + [property: JsonPropertyName("currency")] string Currency, + [property: JsonPropertyName("transactions_data_current_as_of")] string? TransactionsDataCurrentAsOf, + [property: JsonPropertyName("balance_data_current_as_of")] string? BalanceDataCurrentAsOf, + [property: JsonPropertyName("custom_user_provided_identifier")] string? CustomUserProvidedIdentifier, + [property: JsonPropertyName("balance_cents")] string? BalanceCents, + [property: JsonPropertyName("available_balance_cents")] string? AvailableBalanceCents); + +/// Account model. Identical between v1 and v2. +public sealed record Account( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("data")] AccountData Data, + [property: JsonPropertyName("bank")] AccountBank Bank, + [property: JsonPropertyName("legal_entity")] AccountLegalEntity LegalEntity, + [property: JsonPropertyName("tesote_created_at")] string TesoteCreatedAt, + [property: JsonPropertyName("tesote_updated_at")] string TesoteUpdatedAt); + +/// Page-based pagination envelope (v1 / v2 accounts list). +public sealed record PageBasedPagination( + [property: JsonPropertyName("current_page")] int CurrentPage, + [property: JsonPropertyName("per_page")] int PerPage, + [property: JsonPropertyName("total_pages")] int TotalPages, + [property: JsonPropertyName("total_count")] int TotalCount); + +/// Response envelope for GET /v1/accounts and /v2/accounts. +public sealed record AccountListResponse( + [property: JsonPropertyName("total")] int Total, + [property: JsonPropertyName("accounts")] IReadOnlyList Accounts, + [property: JsonPropertyName("pagination")] PageBasedPagination Pagination); diff --git a/packages/csharp/src/Models/Bulk.cs b/packages/csharp/src/Models/Bulk.cs new file mode 100644 index 0000000..7eb5415 --- /dev/null +++ b/packages/csharp/src/Models/Bulk.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Tesote.Sdk.Models; + +/// Per-account result of a POST /v2/transactions/bulk request. +public sealed record BulkResult( + [property: JsonPropertyName("account_id")] string AccountId, + [property: JsonPropertyName("transactions")] IReadOnlyList Transactions, + [property: JsonPropertyName("pagination")] CursorPagination Pagination); + +/// Response envelope for POST /v2/transactions/bulk. +public sealed record BulkResponse( + [property: JsonPropertyName("bulk_results")] IReadOnlyList BulkResults); + +/// Response envelope for GET /v2/transactions/search. +public sealed record SearchResult( + [property: JsonPropertyName("transactions")] IReadOnlyList Transactions, + [property: JsonPropertyName("total")] int Total); diff --git a/packages/csharp/src/Models/PaymentMethod.cs b/packages/csharp/src/Models/PaymentMethod.cs new file mode 100644 index 0000000..09392eb --- /dev/null +++ b/packages/csharp/src/Models/PaymentMethod.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Tesote.Sdk.Models; + +/// Counterparty associated with a beneficiary . +public sealed record PaymentMethodCounterparty( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("name")] string Name); + +/// Tesote-side account associated with a source . +public sealed record PaymentMethodTesoteAccount( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("name")] string Name); + +/// +/// Type-specific payload for a . Common fields are typed; the +/// bag captures method-type-specific properties (e.g. crypto wallet address). +/// +/// +/// Declared as a class (not a positional record) because +/// cannot bind to a constructor parameter. +/// +public sealed class PaymentMethodDetails +{ + /// Bank routing/clabe code, when applicable. + [JsonPropertyName("bank_code")] public string? BankCode { get; init; } + + /// Beneficiary account number, when applicable. + [JsonPropertyName("account_number")] public string? AccountNumber { get; init; } + + /// Account holder name, when applicable. + [JsonPropertyName("holder_name")] public string? HolderName { get; init; } + + /// Identification document type, when applicable. + [JsonPropertyName("identification_type")] public string? IdentificationType { get; init; } + + /// Identification document number, when applicable. + [JsonPropertyName("identification_number")] public string? IdentificationNumber { get; init; } + + /// Open-ended bag for type-specific extras the SDK doesn't model. + [JsonExtensionData] public IDictionary? Extra { get; init; } +} + +/// Stored beneficiary or source payment method. +public sealed record PaymentMethod( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("method_type")] string MethodType, + [property: JsonPropertyName("currency")] string Currency, + [property: JsonPropertyName("label")] string? Label, + [property: JsonPropertyName("details")] PaymentMethodDetails Details, + [property: JsonPropertyName("verified")] bool Verified, + [property: JsonPropertyName("verified_at")] string? VerifiedAt, + [property: JsonPropertyName("last_used_at")] string? LastUsedAt, + [property: JsonPropertyName("counterparty")] PaymentMethodCounterparty? Counterparty, + [property: JsonPropertyName("tesote_account")] PaymentMethodTesoteAccount? TesoteAccount, + [property: JsonPropertyName("created_at")] string CreatedAt, + [property: JsonPropertyName("updated_at")] string UpdatedAt); + +/// Response envelope for GET /v2/payment_methods. +public sealed record PaymentMethodListResponse( + [property: JsonPropertyName("items")] IReadOnlyList Items, + [property: JsonPropertyName("has_more")] bool HasMore, + [property: JsonPropertyName("limit")] int Limit, + [property: JsonPropertyName("offset")] int Offset); diff --git a/packages/csharp/src/Models/Status.cs b/packages/csharp/src/Models/Status.cs new file mode 100644 index 0000000..6c2b024 --- /dev/null +++ b/packages/csharp/src/Models/Status.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Tesote.Sdk.Models; + +/// Response envelope for GET /status and /v2/status. +public sealed record StatusResponse( + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("authenticated")] bool Authenticated); + +/// Identification details returned from GET /whoami and /v2/whoami. +public sealed record WhoamiClient( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("type")] string Type); + +/// Response envelope for GET /whoami and /v2/whoami. +public sealed record WhoamiResponse( + [property: JsonPropertyName("client")] WhoamiClient Client); diff --git a/packages/csharp/src/Models/SyncSession.cs b/packages/csharp/src/Models/SyncSession.cs new file mode 100644 index 0000000..bcf1c6a --- /dev/null +++ b/packages/csharp/src/Models/SyncSession.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Tesote.Sdk.Models; + +/// Failure information attached to a failed . +public sealed record SyncSessionError( + [property: JsonPropertyName("type")] string Type, + [property: JsonPropertyName("message")] string Message); + +/// Performance metrics for a completed . +public sealed record SyncSessionPerformance( + [property: JsonPropertyName("total_duration")] double TotalDuration, + [property: JsonPropertyName("complexity_score")] double ComplexityScore, + [property: JsonPropertyName("sync_speed_score")] double SyncSpeedScore); + +/// Bank-sync session record. +public sealed record SyncSession( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("started_at")] string StartedAt, + [property: JsonPropertyName("completed_at")] string? CompletedAt, + [property: JsonPropertyName("transactions_synced")] int TransactionsSynced, + [property: JsonPropertyName("accounts_count")] int AccountsCount, + [property: JsonPropertyName("error")] SyncSessionError? Error, + [property: JsonPropertyName("performance")] SyncSessionPerformance? Performance); + +/// Acceptance envelope for POST /v2/accounts/{id}/sync. +public sealed record SyncStartResponse( + [property: JsonPropertyName("message")] string Message, + [property: JsonPropertyName("sync_session_id")] string SyncSessionId, + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("started_at")] string StartedAt); + +/// Response envelope for GET /v2/accounts/{id}/sync_sessions. +public sealed record SyncSessionListResponse( + [property: JsonPropertyName("sync_sessions")] IReadOnlyList SyncSessions, + [property: JsonPropertyName("limit")] int Limit, + [property: JsonPropertyName("offset")] int Offset, + [property: JsonPropertyName("has_more")] bool HasMore); diff --git a/packages/csharp/src/Models/SyncTransaction.cs b/packages/csharp/src/Models/SyncTransaction.cs new file mode 100644 index 0000000..c25eb6b --- /dev/null +++ b/packages/csharp/src/Models/SyncTransaction.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Tesote.Sdk.Models; + +/// Flattened Plaid-shaped transaction returned from POST /v2/.../transactions/sync. +public sealed record SyncTransaction( + [property: JsonPropertyName("transaction_id")] string TransactionId, + [property: JsonPropertyName("account_id")] string AccountId, + [property: JsonPropertyName("amount")] decimal Amount, + [property: JsonPropertyName("iso_currency_code")] string IsoCurrencyCode, + [property: JsonPropertyName("unofficial_currency_code")] string? UnofficialCurrencyCode, + [property: JsonPropertyName("date")] string Date, + [property: JsonPropertyName("datetime")] string? Datetime, + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("merchant_name")] string? MerchantName, + [property: JsonPropertyName("pending")] bool Pending, + [property: JsonPropertyName("category")] IReadOnlyList Category, + [property: JsonPropertyName("running_balance_cents")] long? RunningBalanceCents); + +/// Identifiers for a transaction removed since the last sync. +public sealed record SyncRemoved( + [property: JsonPropertyName("transaction_id")] string TransactionId, + [property: JsonPropertyName("account_id")] string AccountId); + +/// Response envelope for POST /v2/.../transactions/sync. +public sealed record SyncResult( + [property: JsonPropertyName("added")] IReadOnlyList Added, + [property: JsonPropertyName("modified")] IReadOnlyList Modified, + [property: JsonPropertyName("removed")] IReadOnlyList Removed, + [property: JsonPropertyName("next_cursor")] string? NextCursor, + [property: JsonPropertyName("has_more")] bool HasMore); diff --git a/packages/csharp/src/Models/Transaction.cs b/packages/csharp/src/Models/Transaction.cs new file mode 100644 index 0000000..ba07dfa --- /dev/null +++ b/packages/csharp/src/Models/Transaction.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Tesote.Sdk.Models; + +/// Counterparty metadata embedded in a . +public sealed record TransactionCounterparty( + [property: JsonPropertyName("name")] string Name); + +/// Category attached to a . +public sealed record TransactionCategory( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("external_category_code")] string? ExternalCategoryCode, + [property: JsonPropertyName("created_at")] string CreatedAt, + [property: JsonPropertyName("updated_at")] string UpdatedAt); + +/// Wire-side transaction-data envelope. +public sealed record TransactionData( + [property: JsonPropertyName("amount_cents")] long AmountCents, + [property: JsonPropertyName("currency")] string Currency, + [property: JsonPropertyName("description")] string Description, + [property: JsonPropertyName("transaction_date")] string TransactionDate, + [property: JsonPropertyName("created_at")] string? CreatedAt, + [property: JsonPropertyName("created_at_date")] string? CreatedAtDate, + [property: JsonPropertyName("note")] string? Note, + [property: JsonPropertyName("external_service_id")] string? ExternalServiceId, + [property: JsonPropertyName("running_balance_cents")] long? RunningBalanceCents); + +/// v1 transaction (also returned from /v2/transactions/{id}). +public sealed record Transaction( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("data")] TransactionData Data, + [property: JsonPropertyName("tesote_imported_at")] string TesoteImportedAt, + [property: JsonPropertyName("tesote_updated_at")] string TesoteUpdatedAt, + [property: JsonPropertyName("transaction_categories")] IReadOnlyList TransactionCategories, + [property: JsonPropertyName("counterparty")] TransactionCounterparty? Counterparty); + +/// Cursor-based pagination envelope used by transaction listings. +public sealed record CursorPagination( + [property: JsonPropertyName("has_more")] bool HasMore, + [property: JsonPropertyName("per_page")] int PerPage, + [property: JsonPropertyName("after_id")] string? AfterId, + [property: JsonPropertyName("before_id")] string? BeforeId); + +/// Response envelope for GET /v1|v2/accounts/{id}/transactions. +public sealed record TransactionListResponse( + [property: JsonPropertyName("total")] int Total, + [property: JsonPropertyName("transactions")] IReadOnlyList Transactions, + [property: JsonPropertyName("pagination")] CursorPagination Pagination); diff --git a/packages/csharp/src/Models/TransactionOrder.cs b/packages/csharp/src/Models/TransactionOrder.cs new file mode 100644 index 0000000..d27a693 --- /dev/null +++ b/packages/csharp/src/Models/TransactionOrder.cs @@ -0,0 +1,129 @@ +using System.Collections.Generic; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace Tesote.Sdk.Models; + +/// Source-account stub embedded in a . +public sealed record TransactionOrderSourceAccount( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("payment_method_id")] string PaymentMethodId); + +/// Destination metadata embedded in a . +public sealed record TransactionOrderDestination( + [property: JsonPropertyName("payment_method_id")] string PaymentMethodId, + [property: JsonPropertyName("counterparty_id")] string? CounterpartyId, + [property: JsonPropertyName("counterparty_name")] string? CounterpartyName); + +/// Optional fee charged on a . +public sealed record TransactionOrderFee( + [property: JsonPropertyName("amount")] decimal Amount, + [property: JsonPropertyName("currency")] string Currency); + +/// Underlying bank transaction created from a successful order. +public sealed record TransactionOrderTesoteTransaction( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("status")] string Status); + +/// Most recent submission attempt for a . +public sealed record TransactionOrderLatestAttempt( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("attempt_number")] int AttemptNumber, + [property: JsonPropertyName("external_reference")] string? ExternalReference, + [property: JsonPropertyName("submitted_at")] string? SubmittedAt, + [property: JsonPropertyName("completed_at")] string? CompletedAt, + [property: JsonPropertyName("error_code")] string? ErrorCode, + [property: JsonPropertyName("error_message")] string? ErrorMessage); + +/// Beneficiary payload accepted by POST /v2/.../transaction_orders when no payment-method id is supplied. +public sealed record Beneficiary( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("bank_code")] string? BankCode, + [property: JsonPropertyName("account_number")] string? AccountNumber, + [property: JsonPropertyName("identification_type")] string? IdentificationType, + [property: JsonPropertyName("identification_number")] string? IdentificationNumber); + +/// Single transfer order — the unit of payment in v2. +public sealed record TransactionOrder( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("amount")] decimal Amount, + [property: JsonPropertyName("currency")] string Currency, + [property: JsonPropertyName("description")] string Description, + [property: JsonPropertyName("reference")] string? Reference, + [property: JsonPropertyName("external_reference")] string? ExternalReference, + [property: JsonPropertyName("idempotency_key")] string? IdempotencyKey, + [property: JsonPropertyName("batch_id")] string? BatchId, + [property: JsonPropertyName("scheduled_for")] string? ScheduledFor, + [property: JsonPropertyName("approved_at")] string? ApprovedAt, + [property: JsonPropertyName("submitted_at")] string? SubmittedAt, + [property: JsonPropertyName("completed_at")] string? CompletedAt, + [property: JsonPropertyName("failed_at")] string? FailedAt, + [property: JsonPropertyName("cancelled_at")] string? CancelledAt, + [property: JsonPropertyName("source_account")] TransactionOrderSourceAccount SourceAccount, + [property: JsonPropertyName("destination")] TransactionOrderDestination Destination, + [property: JsonPropertyName("fee")] TransactionOrderFee? Fee, + [property: JsonPropertyName("execution_strategy")] string? ExecutionStrategy, + [property: JsonPropertyName("tesote_transaction")] TransactionOrderTesoteTransaction? TesoteTransaction, + [property: JsonPropertyName("latest_attempt")] TransactionOrderLatestAttempt? LatestAttempt, + [property: JsonPropertyName("metadata")] JsonObject? Metadata, + [property: JsonPropertyName("created_at")] string CreatedAt, + [property: JsonPropertyName("updated_at")] string UpdatedAt); + +/// Response envelope for GET /v2/.../transaction_orders. +public sealed record TransactionOrderListResponse( + [property: JsonPropertyName("items")] IReadOnlyList Items, + [property: JsonPropertyName("has_more")] bool HasMore, + [property: JsonPropertyName("limit")] int Limit, + [property: JsonPropertyName("offset")] int Offset); + +/// Single error entry returned from POST /v2/.../batches. +public sealed record BatchOrderError( + [property: JsonPropertyName("index")] int? Index, + [property: JsonPropertyName("error")] string? Error, + [property: JsonPropertyName("error_code")] string? ErrorCode); + +/// Response envelope for POST /v2/.../batches. +public sealed record BatchCreateResponse( + [property: JsonPropertyName("batch_id")] string BatchId, + [property: JsonPropertyName("orders")] IReadOnlyList Orders, + [property: JsonPropertyName("errors")] IReadOnlyList Errors); + +/// Per-status order counts inside a batch. +public sealed record BatchStatusCounts( + [property: JsonPropertyName("draft")] int Draft, + [property: JsonPropertyName("pending_approval")] int PendingApproval, + [property: JsonPropertyName("approved")] int Approved, + [property: JsonPropertyName("processing")] int Processing, + [property: JsonPropertyName("completed")] int Completed, + [property: JsonPropertyName("failed")] int Failed, + [property: JsonPropertyName("cancelled")] int Cancelled); + +/// Response envelope for GET /v2/.../batches/{batch_id}. +public sealed record BatchSummary( + [property: JsonPropertyName("batch_id")] string BatchId, + [property: JsonPropertyName("total_orders")] int TotalOrders, + [property: JsonPropertyName("total_amount_cents")] long TotalAmountCents, + [property: JsonPropertyName("amount_currency")] string AmountCurrency, + [property: JsonPropertyName("statuses")] BatchStatusCounts Statuses, + [property: JsonPropertyName("batch_status")] string BatchStatus, + [property: JsonPropertyName("created_at")] string CreatedAt, + [property: JsonPropertyName("orders")] IReadOnlyList Orders); + +/// Response envelope for POST /v2/.../batches/{batch_id}/approve. +public sealed record BatchApproveResponse( + [property: JsonPropertyName("approved")] int Approved, + [property: JsonPropertyName("failed")] int Failed); + +/// Response envelope for POST /v2/.../batches/{batch_id}/submit. +public sealed record BatchSubmitResponse( + [property: JsonPropertyName("enqueued")] int Enqueued, + [property: JsonPropertyName("failed")] int Failed); + +/// Response envelope for POST /v2/.../batches/{batch_id}/cancel. +public sealed record BatchCancelResponse( + [property: JsonPropertyName("cancelled")] int Cancelled, + [property: JsonPropertyName("skipped")] int Skipped, + [property: JsonPropertyName("errors")] IReadOnlyList Errors); diff --git a/packages/csharp/src/RawResponse.cs b/packages/csharp/src/RawResponse.cs new file mode 100644 index 0000000..3d80367 --- /dev/null +++ b/packages/csharp/src/RawResponse.cs @@ -0,0 +1,17 @@ +namespace Tesote.Sdk; + +/// +/// Non-JSON HTTP response payload, returned from . +/// Exposes the raw bytes plus the headers callers need to interpret them. +/// +/// Raw response bytes (may be empty, never null). +/// Value of Content-Type, or application/octet-stream. +/// Value of Content-Disposition (filename hints). +/// Value of X-Request-Id. +/// HTTP status code (always 2xx since errors throw). +public sealed record RawResponse( + byte[] Body, + string ContentType, + string? ContentDisposition, + string? RequestId, + int HttpStatus); diff --git a/packages/csharp/src/Tesote.Sdk.csproj b/packages/csharp/src/Tesote.Sdk.csproj index 8105784..1c1f067 100644 --- a/packages/csharp/src/Tesote.Sdk.csproj +++ b/packages/csharp/src/Tesote.Sdk.csproj @@ -16,7 +16,7 @@ Tesote.Sdk - 0.1.0 + 0.2.0 Tesote Tesote Official C# / .NET SDK for the equipo.tesote.com API. diff --git a/packages/csharp/src/Transport.cs b/packages/csharp/src/Transport.cs index 3625e3a..24a5830 100644 --- a/packages/csharp/src/Transport.cs +++ b/packages/csharp/src/Transport.cs @@ -33,7 +33,7 @@ public sealed class Transport : IAsyncDisposable, IDisposable public const string DefaultBaseUrl = "https://equipo.tesote.com/api"; /// Current SDK version, exposed for User-Agent. - public const string SdkVersion = "0.1.0"; + public const string SdkVersion = "0.2.0"; private static readonly HashSet MutatingMethods = new(StringComparer.OrdinalIgnoreCase) { "POST", "PUT", "PATCH", "DELETE" }; @@ -259,6 +259,137 @@ public Transport(ClientOptions options) throw new NetworkException("unexpected transport state", summary, _retryPolicy.MaxAttempts, null); } + /// + /// Send a request and return the raw response body alongside content-type. + /// Used for file-download endpoints (CSV / JSON export) where the body is not a JSON envelope. + /// Bypasses caching; still applies retries, rate-limit awareness, idempotency, and error mapping. + /// + public async Task RequestRawAsync(RequestOptions options, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + ThrowIfDisposed(); + + var summary = RequestSummary.Create( + options.Method, options.Path, options.Query, + options.BodyShape, RedactBearer(_apiKey)); + + var idempotencyKey = options.IdempotencyKey; + if (idempotencyKey is null && MutatingMethods.Contains(options.Method)) + { + idempotencyKey = Guid.NewGuid().ToString(); + } + + Exception? lastTransport = null; + ApiException? lastApi = null; + + for (var attempt = 1; attempt <= _retryPolicy.MaxAttempts; attempt++) + { + using var request = BuildRequest(options, idempotencyKey); + HttpResponseMessage? response = null; + byte[]? body = null; + try + { + response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + body = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); + } + catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + response?.Dispose(); + lastTransport = ex; + _logger?.Invoke(new LogEvent(summary, attempt, -1, ex)); + if (!_retryPolicy.RetryOnNetwork || attempt == _retryPolicy.MaxAttempts) + { + throw new TesoteTimeoutException("request timed out", summary, attempt, ex); + } + await Task.Delay(Backoff(attempt, null), cancellationToken).ConfigureAwait(false); + continue; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + response?.Dispose(); + throw; + } + catch (HttpRequestException ex) when (IsTlsError(ex)) + { + response?.Dispose(); + throw new TlsException("TLS error: " + ex.Message, summary, attempt, ex); + } + catch (HttpRequestException ex) + { + response?.Dispose(); + lastTransport = ex; + _logger?.Invoke(new LogEvent(summary, attempt, -1, ex)); + if (!_retryPolicy.RetryOnNetwork || attempt == _retryPolicy.MaxAttempts) + { + throw new NetworkException(ex.Message, summary, attempt, ex); + } + await Task.Delay(Backoff(attempt, null), cancellationToken).ConfigureAwait(false); + continue; + } + catch (IOException ex) + { + response?.Dispose(); + lastTransport = ex; + _logger?.Invoke(new LogEvent(summary, attempt, -1, ex)); + if (!_retryPolicy.RetryOnNetwork || attempt == _retryPolicy.MaxAttempts) + { + throw new NetworkException(ex.Message, summary, attempt, ex); + } + await Task.Delay(Backoff(attempt, null), cancellationToken).ConfigureAwait(false); + continue; + } + catch (SocketException ex) + { + response?.Dispose(); + lastTransport = ex; + _logger?.Invoke(new LogEvent(summary, attempt, -1, ex)); + if (!_retryPolicy.RetryOnNetwork || attempt == _retryPolicy.MaxAttempts) + { + throw new NetworkException(ex.Message, summary, attempt, ex); + } + await Task.Delay(Backoff(attempt, null), cancellationToken).ConfigureAwait(false); + continue; + } + + using (response) + { + CaptureRateLimit(response); + var status = (int)response.StatusCode; + var requestId = FirstHeader(response, "X-Request-Id"); + _logger?.Invoke(new LogEvent(summary, attempt, status, null)); + + if (status >= 200 && status < 300) + { + var contentType = response.Content.Headers.ContentType?.ToString() ?? "application/octet-stream"; + var contentDisposition = response.Content.Headers.ContentDisposition?.ToString(); + return new RawResponse(body ?? Array.Empty(), contentType, contentDisposition, requestId, status); + } + + var api = BuildApiException(summary, response, body, requestId, attempt); + lastApi = api; + + if (ShouldRetry(status, attempt)) + { + var sleepFor = Backoff(attempt, RetryAfterSeconds(response)); + await Task.Delay(sleepFor, cancellationToken).ConfigureAwait(false); + continue; + } + throw api; + } + } + + if (lastApi is not null) + { + throw lastApi; + } + if (lastTransport is not null) + { + throw new NetworkException("retries exhausted", summary, _retryPolicy.MaxAttempts, lastTransport); + } + throw new NetworkException("unexpected transport state", summary, _retryPolicy.MaxAttempts, null); + } + private HttpRequestMessage BuildRequest(RequestOptions options, string? idempotencyKey) { var uri = BuildUri(options); diff --git a/packages/csharp/src/V1/AccountsClient.cs b/packages/csharp/src/V1/AccountsClient.cs index 0e6a7c7..3c31801 100644 --- a/packages/csharp/src/V1/AccountsClient.cs +++ b/packages/csharp/src/V1/AccountsClient.cs @@ -1,14 +1,12 @@ using System; -using System.Collections.Generic; -using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; +using Tesote.Sdk.Internal; +using Tesote.Sdk.Models; namespace Tesote.Sdk.V1; -/// -/// v1 Accounts resource. Read-only listing and lookup. -/// +/// v1 Accounts resource: list, get, and per-account transaction listing. public sealed class AccountsClient { private const string BasePath = "/v1/accounts"; @@ -20,19 +18,64 @@ internal AccountsClient(Transport transport) _transport = transport; } - /// List accounts. Returns the raw response envelope until typed models land. - public Task ListAsync(IReadOnlyDictionary? query = null, CancellationToken cancellationToken = default) + /// List accounts with page-based pagination. + public async Task ListAsync( + int? page = null, + int? perPage = null, + string? include = null, + string? sort = null, + CancellationToken ct = default) { + var query = new QueryBuilder() + .Add("page", page) + .Add("per_page", perPage) + .Add("include", include) + .Add("sort", sort) + .BuildOrNull(); + var opts = RequestOptions.Get(BasePath); opts.Query = query; - return _transport.RequestAsync(opts, cancellationToken); + opts.CacheTtl = TimeSpan.FromMinutes(1); + var node = await _transport.RequestAsync(opts, ct).ConfigureAwait(false); + return Json.Deserialize(node); } /// Fetch a single account by id. - public Task GetAsync(string accountId, CancellationToken cancellationToken = default) + public async Task GetAsync(string accountId, CancellationToken ct = default) { ArgumentException.ThrowIfNullOrEmpty(accountId); var opts = RequestOptions.Get(BasePath + "/" + Uri.EscapeDataString(accountId)); - return _transport.RequestAsync(opts, cancellationToken); + opts.CacheTtl = TimeSpan.FromMinutes(5); + var node = await _transport.RequestAsync(opts, ct).ConfigureAwait(false); + return Json.Deserialize(node); + } + + /// List transactions for a single account, with optional date and cursor filters. + public async Task ListTransactionsAsync( + string accountId, + string? startDate = null, + string? endDate = null, + string? scope = null, + int? page = null, + int? perPage = null, + string? transactionsAfterId = null, + string? transactionsBeforeId = null, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(accountId); + var query = new QueryBuilder() + .Add("start_date", startDate) + .Add("end_date", endDate) + .Add("scope", scope) + .Add("page", page) + .Add("per_page", perPage) + .Add("transactions_after_id", transactionsAfterId) + .Add("transactions_before_id", transactionsBeforeId) + .BuildOrNull(); + + var opts = RequestOptions.Get(BasePath + "/" + Uri.EscapeDataString(accountId) + "/transactions"); + opts.Query = query; + var node = await _transport.RequestAsync(opts, ct).ConfigureAwait(false); + return Json.Deserialize(node); } } diff --git a/packages/csharp/src/V1/StatusClient.cs b/packages/csharp/src/V1/StatusClient.cs new file mode 100644 index 0000000..9398b58 --- /dev/null +++ b/packages/csharp/src/V1/StatusClient.cs @@ -0,0 +1,31 @@ +using System.Threading; +using System.Threading.Tasks; +using Tesote.Sdk.Internal; +using Tesote.Sdk.Models; + +namespace Tesote.Sdk.V1; + +/// v1 status + whoami endpoints (cross-version: /status and /whoami). +public sealed class StatusClient +{ + private readonly Transport _transport; + + internal StatusClient(Transport transport) + { + _transport = transport; + } + + /// Public status check. Auth not required server-side, but the SDK sends the bearer anyway. + public async Task GetAsync(CancellationToken ct = default) + { + var node = await _transport.RequestAsync(RequestOptions.Get("/status"), ct).ConfigureAwait(false); + return Json.Deserialize(node); + } + + /// Identifies the bearer-token holder (workspace or user). + public async Task WhoamiAsync(CancellationToken ct = default) + { + var node = await _transport.RequestAsync(RequestOptions.Get("/whoami"), ct).ConfigureAwait(false); + return Json.Deserialize(node); + } +} diff --git a/packages/csharp/src/V1/TransactionsClient.cs b/packages/csharp/src/V1/TransactionsClient.cs new file mode 100644 index 0000000..6c77730 --- /dev/null +++ b/packages/csharp/src/V1/TransactionsClient.cs @@ -0,0 +1,30 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Tesote.Sdk.Internal; +using Tesote.Sdk.Models; + +namespace Tesote.Sdk.V1; + +/// v1 Transactions resource. Lookup-by-id only; list lives on the account client. +public sealed class TransactionsClient +{ + private const string BasePath = "/v1/transactions"; + + private readonly Transport _transport; + + internal TransactionsClient(Transport transport) + { + _transport = transport; + } + + /// Fetch a single transaction by id. + public async Task GetAsync(string transactionId, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(transactionId); + var opts = RequestOptions.Get(BasePath + "/" + Uri.EscapeDataString(transactionId)); + opts.CacheTtl = TimeSpan.FromMinutes(5); + var node = await _transport.RequestAsync(opts, ct).ConfigureAwait(false); + return Json.Deserialize(node); + } +} diff --git a/packages/csharp/src/V1/V1Client.cs b/packages/csharp/src/V1/V1Client.cs index 47294eb..618fcaf 100644 --- a/packages/csharp/src/V1/V1Client.cs +++ b/packages/csharp/src/V1/V1Client.cs @@ -5,31 +5,33 @@ namespace Tesote.Sdk.V1; /// /// v1 client. Read-only foundation: status, accounts, transactions. -/// -/// 0.1.0 ships transport plumbing plus list/get; -/// other resources stub with until wired. /// public sealed class V1Client : IAsyncDisposable, IDisposable { /// The transport instance, exposed for advanced users. public Transport Transport { get; } + /// Status + whoami resource client. + public StatusClient Status { get; } + /// Accounts resource client. public AccountsClient Accounts { get; } + /// Transactions resource client. + public TransactionsClient Transactions { get; } + /// Construct a v1 client from . public V1Client(ClientOptions options) { ArgumentNullException.ThrowIfNull(options); Transport = new Transport(options); + Status = new StatusClient(Transport); Accounts = new AccountsClient(Transport); + Transactions = new TransactionsClient(Transport); } - /// Status endpoint — not implemented in 0.1.0. - public Task StatusAsync() => throw new NotImplementedException("not implemented"); - - /// Transactions endpoint — not implemented in 0.1.0. - public Task TransactionsAsync() => throw new NotImplementedException("not implemented"); + /// Last captured rate-limit snapshot. + public RateLimitSnapshot LastRateLimit => Transport.LastRateLimit; /// Async dispose. public ValueTask DisposeAsync() diff --git a/packages/csharp/src/V2/AccountsClient.cs b/packages/csharp/src/V2/AccountsClient.cs index feeb34a..85843f7 100644 --- a/packages/csharp/src/V2/AccountsClient.cs +++ b/packages/csharp/src/V2/AccountsClient.cs @@ -1,13 +1,14 @@ using System; -using System.Collections.Generic; -using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; +using Tesote.Sdk.Internal; +using Tesote.Sdk.Models; namespace Tesote.Sdk.V2; /// -/// v2 Accounts resource. Same shape as v1 plus the v2-only endpoints in subsequent releases. +/// v2 Accounts resource. Same shape as v1 plus a sync trigger; transaction listing, +/// export, and sync live on dedicated client classes. /// public sealed class AccountsClient { @@ -20,19 +21,48 @@ internal AccountsClient(Transport transport) _transport = transport; } - /// List accounts. Returns the raw response envelope until typed models land. - public Task ListAsync(IReadOnlyDictionary? query = null, CancellationToken cancellationToken = default) + /// List accounts with page-based pagination. + public async Task ListAsync( + int? page = null, + int? perPage = null, + string? include = null, + string? sort = null, + CancellationToken ct = default) { + var query = new QueryBuilder() + .Add("page", page) + .Add("per_page", perPage) + .Add("include", include) + .Add("sort", sort) + .BuildOrNull(); + var opts = RequestOptions.Get(BasePath); opts.Query = query; - return _transport.RequestAsync(opts, cancellationToken); + opts.CacheTtl = TimeSpan.FromMinutes(1); + var node = await _transport.RequestAsync(opts, ct).ConfigureAwait(false); + return Json.Deserialize(node); } /// Fetch a single account by id. - public Task GetAsync(string accountId, CancellationToken cancellationToken = default) + public async Task GetAsync(string accountId, CancellationToken ct = default) { ArgumentException.ThrowIfNullOrEmpty(accountId); var opts = RequestOptions.Get(BasePath + "/" + Uri.EscapeDataString(accountId)); - return _transport.RequestAsync(opts, cancellationToken); + opts.CacheTtl = TimeSpan.FromMinutes(5); + var node = await _transport.RequestAsync(opts, ct).ConfigureAwait(false); + return Json.Deserialize(node); + } + + /// Trigger a bank sync for an account. Returns the started sync session metadata. + public async Task SyncAsync( + string accountId, + string? idempotencyKey = null, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(accountId); + var path = BasePath + "/" + Uri.EscapeDataString(accountId) + "/sync"; + var opts = Requests.Json("POST", path, body: null, idempotencyKey: idempotencyKey); + var node = await _transport.RequestAsync(opts, ct).ConfigureAwait(false); + return Json.Deserialize(node); } } diff --git a/packages/csharp/src/V2/BatchesClient.cs b/packages/csharp/src/V2/BatchesClient.cs new file mode 100644 index 0000000..3950050 --- /dev/null +++ b/packages/csharp/src/V2/BatchesClient.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Tesote.Sdk.Internal; +using Tesote.Sdk.Models; + +namespace Tesote.Sdk.V2; + +/// v2 batches resource — atomic creation of multiple orders + bulk lifecycle. +public sealed class BatchesClient +{ + private readonly Transport _transport; + + internal BatchesClient(Transport transport) + { + _transport = transport; + } + + /// Single order entry inside a batch create payload. + public sealed class BatchOrderInput + { + /// Existing payment-method id (null when supplying a beneficiary). + public string? DestinationPaymentMethodId { get; set; } + /// Inline beneficiary; mutually exclusive with the payment-method id. + public Beneficiary? Beneficiary { get; set; } + /// Order amount as a decimal string. + public string Amount { get; set; } = "0"; + /// ISO currency code. + public string Currency { get; set; } = "VES"; + /// Free-text description. + public string Description { get; set; } = string.Empty; + /// Optional schedule. + public string? ScheduledFor { get; set; } + /// Optional metadata bag. + public IDictionary? Metadata { get; set; } + } + + /// Create a new batch of transaction orders for an account. + public async Task CreateAsync( + string accountId, + IReadOnlyList orders, + string? idempotencyKey = null, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(accountId); + ArgumentNullException.ThrowIfNull(orders); + var path = "/v2/accounts/" + Uri.EscapeDataString(accountId) + "/batches"; + var serialized = new List>(orders.Count); + foreach (var o in orders) + { + serialized.Add(new Dictionary + { + ["destination_payment_method_id"] = o.DestinationPaymentMethodId, + ["beneficiary"] = o.Beneficiary, + ["amount"] = o.Amount, + ["currency"] = o.Currency, + ["description"] = o.Description, + ["scheduled_for"] = o.ScheduledFor, + ["metadata"] = o.Metadata, + }); + } + var body = new Dictionary { ["orders"] = serialized }; + var opts = Requests.Json("POST", path, body, idempotencyKey); + var node = await _transport.RequestAsync(opts, ct).ConfigureAwait(false); + return Json.Deserialize(node); + } + + /// Fetch the summary view for a batch. + public async Task GetAsync(string accountId, string batchId, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(accountId); + ArgumentException.ThrowIfNullOrEmpty(batchId); + var path = "/v2/accounts/" + Uri.EscapeDataString(accountId) + + "/batches/" + Uri.EscapeDataString(batchId); + var opts = RequestOptions.Get(path); + var node = await _transport.RequestAsync(opts, ct).ConfigureAwait(false); + return Json.Deserialize(node); + } + + /// Approve every draft order in a batch. + public async Task ApproveAsync( + string accountId, + string batchId, + string? idempotencyKey = null, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(accountId); + ArgumentException.ThrowIfNullOrEmpty(batchId); + var path = "/v2/accounts/" + Uri.EscapeDataString(accountId) + + "/batches/" + Uri.EscapeDataString(batchId) + "/approve"; + var opts = Requests.Json("POST", path, body: null, idempotencyKey: idempotencyKey); + var node = await _transport.RequestAsync(opts, ct).ConfigureAwait(false); + return Json.Deserialize(node); + } + + /// Submit every approved order in a batch. + public async Task SubmitAsync( + string accountId, + string batchId, + string? token = null, + string? idempotencyKey = null, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(accountId); + ArgumentException.ThrowIfNullOrEmpty(batchId); + var path = "/v2/accounts/" + Uri.EscapeDataString(accountId) + + "/batches/" + Uri.EscapeDataString(batchId) + "/submit"; + var body = new Dictionary { ["token"] = token }; + var opts = Requests.Json("POST", path, body, idempotencyKey); + var node = await _transport.RequestAsync(opts, ct).ConfigureAwait(false); + return Json.Deserialize(node); + } + + /// Cancel every cancellable order in a batch. + public async Task CancelAsync( + string accountId, + string batchId, + string? idempotencyKey = null, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(accountId); + ArgumentException.ThrowIfNullOrEmpty(batchId); + var path = "/v2/accounts/" + Uri.EscapeDataString(accountId) + + "/batches/" + Uri.EscapeDataString(batchId) + "/cancel"; + var opts = Requests.Json("POST", path, body: null, idempotencyKey: idempotencyKey); + var node = await _transport.RequestAsync(opts, ct).ConfigureAwait(false); + return Json.Deserialize(node); + } +} diff --git a/packages/csharp/src/V2/PaymentMethodsClient.cs b/packages/csharp/src/V2/PaymentMethodsClient.cs new file mode 100644 index 0000000..b9693a5 --- /dev/null +++ b/packages/csharp/src/V2/PaymentMethodsClient.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Tesote.Sdk.Internal; +using Tesote.Sdk.Models; + +namespace Tesote.Sdk.V2; + +/// v2 payment-methods resource — list, get, create, update, soft-delete. +public sealed class PaymentMethodsClient +{ + private const string BasePath = "/v2/payment_methods"; + + private readonly Transport _transport; + + internal PaymentMethodsClient(Transport transport) + { + _transport = transport; + } + + /// List payment methods (offset paginated). Soft-deleted entries are excluded. + public async Task ListAsync( + int? limit = null, + int? offset = null, + string? methodType = null, + string? currency = null, + string? counterpartyId = null, + bool? verified = null, + CancellationToken ct = default) + { + var query = new QueryBuilder() + .Add("limit", limit) + .Add("offset", offset) + .Add("method_type", methodType) + .Add("currency", currency) + .Add("counterparty_id", counterpartyId) + .Add("verified", verified) + .BuildOrNull(); + var opts = RequestOptions.Get(BasePath); + opts.Query = query; + var node = await _transport.RequestAsync(opts, ct).ConfigureAwait(false); + return Json.Deserialize(node); + } + + /// Fetch a single payment method by id. + public async Task GetAsync(string id, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(id); + var opts = RequestOptions.Get(BasePath + "/" + Uri.EscapeDataString(id)); + var node = await _transport.RequestAsync(opts, ct).ConfigureAwait(false); + return Json.Deserialize(node); + } + + /// Counterparty stub accepted by create when no counterparty_id is provided. + public sealed class CounterpartyInput + { + /// Counterparty display name. + public string Name { get; set; } = string.Empty; + } + + /// Type-specific payload for a payment method. + public sealed class DetailsInput + { + /// Bank routing/clabe code. + public string? BankCode { get; set; } + /// Account number. + public string? AccountNumber { get; set; } + /// Holder full name. + public string? HolderName { get; set; } + /// Identification document type. + public string? IdentificationType { get; set; } + /// Identification document number. + public string? IdentificationNumber { get; set; } + } + + /// Body for POST/PATCH /v2/payment_methods. + public sealed class WriteRequest + { + /// Method type (e.g. bank_account, pago_movil, wire). + public string? MethodType { get; set; } + /// ISO currency code. + public string? Currency { get; set; } + /// Optional human label. + public string? Label { get; set; } + /// Existing counterparty id; mutually exclusive with . + public string? CounterpartyId { get; set; } + /// Inline counterparty stub. + public CounterpartyInput? Counterparty { get; set; } + /// Type-specific details payload. + public DetailsInput? Details { get; set; } + } + + /// Create a new payment method. + public Task CreateAsync( + WriteRequest request, + string? idempotencyKey = null, + CancellationToken ct = default) => Write("POST", BasePath, request, idempotencyKey, ct); + + /// Patch an existing payment method. + public Task UpdateAsync( + string id, + WriteRequest request, + string? idempotencyKey = null, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(id); + return Write("PATCH", BasePath + "/" + Uri.EscapeDataString(id), request, idempotencyKey, ct); + } + + /// Soft-delete a payment method. 204 No Content on success. + public async Task DeleteAsync(string id, string? idempotencyKey = null, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(id); + var opts = Requests.Json("DELETE", BasePath + "/" + Uri.EscapeDataString(id), body: null, idempotencyKey: idempotencyKey); + await _transport.RequestAsync(opts, ct).ConfigureAwait(false); + } + + private async Task Write(string method, string path, WriteRequest request, string? idempotencyKey, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(request); + var inner = new Dictionary + { + ["method_type"] = request.MethodType, + ["currency"] = request.Currency, + ["label"] = request.Label, + ["counterparty_id"] = request.CounterpartyId, + ["counterparty"] = request.Counterparty is null ? null : new Dictionary { ["name"] = request.Counterparty.Name }, + ["details"] = request.Details is null ? null : new Dictionary + { + ["bank_code"] = request.Details.BankCode, + ["account_number"] = request.Details.AccountNumber, + ["holder_name"] = request.Details.HolderName, + ["identification_type"] = request.Details.IdentificationType, + ["identification_number"] = request.Details.IdentificationNumber, + }, + }; + var body = new Dictionary { ["payment_method"] = inner }; + var opts = Requests.Json(method, path, body, idempotencyKey); + var node = await _transport.RequestAsync(opts, ct).ConfigureAwait(false); + return Json.Deserialize(node); + } +} diff --git a/packages/csharp/src/V2/StatusClient.cs b/packages/csharp/src/V2/StatusClient.cs new file mode 100644 index 0000000..99be4fd --- /dev/null +++ b/packages/csharp/src/V2/StatusClient.cs @@ -0,0 +1,31 @@ +using System.Threading; +using System.Threading.Tasks; +using Tesote.Sdk.Internal; +using Tesote.Sdk.Models; + +namespace Tesote.Sdk.V2; + +/// v2 status + whoami endpoints. +public sealed class StatusClient +{ + private readonly Transport _transport; + + internal StatusClient(Transport transport) + { + _transport = transport; + } + + /// Public status check. + public async Task GetAsync(CancellationToken ct = default) + { + var node = await _transport.RequestAsync(RequestOptions.Get("/v2/status"), ct).ConfigureAwait(false); + return Json.Deserialize(node); + } + + /// Identifies the bearer-token holder. + public async Task WhoamiAsync(CancellationToken ct = default) + { + var node = await _transport.RequestAsync(RequestOptions.Get("/v2/whoami"), ct).ConfigureAwait(false); + return Json.Deserialize(node); + } +} diff --git a/packages/csharp/src/V2/SyncSessionsClient.cs b/packages/csharp/src/V2/SyncSessionsClient.cs new file mode 100644 index 0000000..1dcc915 --- /dev/null +++ b/packages/csharp/src/V2/SyncSessionsClient.cs @@ -0,0 +1,51 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Tesote.Sdk.Internal; +using Tesote.Sdk.Models; + +namespace Tesote.Sdk.V2; + +/// v2 sync-sessions resource — list and lookup per account. +public sealed class SyncSessionsClient +{ + private readonly Transport _transport; + + internal SyncSessionsClient(Transport transport) + { + _transport = transport; + } + + /// List sync sessions for an account, ordered by creation desc. + public async Task ListAsync( + string accountId, + int? limit = null, + int? offset = null, + string? status = null, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(accountId); + var path = "/v2/accounts/" + Uri.EscapeDataString(accountId) + "/sync_sessions"; + var query = new QueryBuilder() + .Add("limit", limit) + .Add("offset", offset) + .Add("status", status) + .BuildOrNull(); + var opts = RequestOptions.Get(path); + opts.Query = query; + var node = await _transport.RequestAsync(opts, ct).ConfigureAwait(false); + return Json.Deserialize(node); + } + + /// Fetch a single sync session by id. + public async Task GetAsync(string accountId, string sessionId, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(accountId); + ArgumentException.ThrowIfNullOrEmpty(sessionId); + var path = "/v2/accounts/" + Uri.EscapeDataString(accountId) + + "/sync_sessions/" + Uri.EscapeDataString(sessionId); + var opts = RequestOptions.Get(path); + var node = await _transport.RequestAsync(opts, ct).ConfigureAwait(false); + return Json.Deserialize(node); + } +} diff --git a/packages/csharp/src/V2/TransactionOrdersClient.cs b/packages/csharp/src/V2/TransactionOrdersClient.cs new file mode 100644 index 0000000..e80d3c0 --- /dev/null +++ b/packages/csharp/src/V2/TransactionOrdersClient.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Tesote.Sdk.Internal; +using Tesote.Sdk.Models; + +namespace Tesote.Sdk.V2; + +/// v2 transaction-orders resource — list, get, create, submit, cancel. +public sealed class TransactionOrdersClient +{ + private readonly Transport _transport; + + internal TransactionOrdersClient(Transport transport) + { + _transport = transport; + } + + /// List transaction orders for an account. + public async Task ListAsync( + string accountId, + int? limit = null, + int? offset = null, + string? status = null, + string? createdAfter = null, + string? createdBefore = null, + string? batchId = null, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(accountId); + var path = "/v2/accounts/" + Uri.EscapeDataString(accountId) + "/transaction_orders"; + var query = new QueryBuilder() + .Add("limit", limit) + .Add("offset", offset) + .Add("status", status) + .Add("created_after", createdAfter) + .Add("created_before", createdBefore) + .Add("batch_id", batchId) + .BuildOrNull(); + var opts = RequestOptions.Get(path); + opts.Query = query; + var node = await _transport.RequestAsync(opts, ct).ConfigureAwait(false); + return Json.Deserialize(node); + } + + /// Fetch a single transaction order. + public async Task GetAsync(string accountId, string orderId, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(accountId); + ArgumentException.ThrowIfNullOrEmpty(orderId); + var path = "/v2/accounts/" + Uri.EscapeDataString(accountId) + + "/transaction_orders/" + Uri.EscapeDataString(orderId); + var opts = RequestOptions.Get(path); + var node = await _transport.RequestAsync(opts, ct).ConfigureAwait(false); + return Json.Deserialize(node); + } + + /// Request body for POST /v2/.../transaction_orders. + public sealed class CreateRequest + { + /// Existing payment-method id, or null when supplying a beneficiary. + public string? DestinationPaymentMethodId { get; set; } + /// Inline beneficiary; mutually exclusive with . + public Beneficiary? Beneficiary { get; set; } + /// Order amount as a decimal string. + public string Amount { get; set; } = "0"; + /// ISO currency code, e.g. VES. + public string Currency { get; set; } = "VES"; + /// Free-text description. + public string Description { get; set; } = string.Empty; + /// Optional ISO8601 schedule timestamp. + public string? ScheduledFor { get; set; } + /// Server-side idempotency key (separate from the transport idempotency header). + public string? IdempotencyKey { get; set; } + /// Optional metadata bag. + public IDictionary? Metadata { get; set; } + } + + /// Create a draft transaction order. + public async Task CreateAsync( + string accountId, + CreateRequest request, + string? idempotencyKey = null, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(accountId); + ArgumentNullException.ThrowIfNull(request); + var path = "/v2/accounts/" + Uri.EscapeDataString(accountId) + "/transaction_orders"; + var inner = new Dictionary + { + ["destination_payment_method_id"] = request.DestinationPaymentMethodId, + ["beneficiary"] = request.Beneficiary, + ["amount"] = request.Amount, + ["currency"] = request.Currency, + ["description"] = request.Description, + ["scheduled_for"] = request.ScheduledFor, + ["idempotency_key"] = request.IdempotencyKey, + ["metadata"] = request.Metadata, + }; + var body = new Dictionary { ["transaction_order"] = inner }; + var opts = Requests.Json("POST", path, body, idempotencyKey); + var node = await _transport.RequestAsync(opts, ct).ConfigureAwait(false); + return Json.Deserialize(node); + } + + /// Submit a draft (or pending-approval) order for processing. + public async Task SubmitAsync( + string accountId, + string orderId, + string? token = null, + string? idempotencyKey = null, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(accountId); + ArgumentException.ThrowIfNullOrEmpty(orderId); + var path = "/v2/accounts/" + Uri.EscapeDataString(accountId) + + "/transaction_orders/" + Uri.EscapeDataString(orderId) + "/submit"; + var body = new Dictionary { ["token"] = token }; + var opts = Requests.Json("POST", path, body, idempotencyKey); + var node = await _transport.RequestAsync(opts, ct).ConfigureAwait(false); + return Json.Deserialize(node); + } + + /// Cancel an order; transitions to cancelled. + public async Task CancelAsync( + string accountId, + string orderId, + string? idempotencyKey = null, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(accountId); + ArgumentException.ThrowIfNullOrEmpty(orderId); + var path = "/v2/accounts/" + Uri.EscapeDataString(accountId) + + "/transaction_orders/" + Uri.EscapeDataString(orderId) + "/cancel"; + var opts = Requests.Json("POST", path, body: null, idempotencyKey: idempotencyKey); + var node = await _transport.RequestAsync(opts, ct).ConfigureAwait(false); + return Json.Deserialize(node); + } +} diff --git a/packages/csharp/src/V2/TransactionsClient.cs b/packages/csharp/src/V2/TransactionsClient.cs new file mode 100644 index 0000000..07df226 --- /dev/null +++ b/packages/csharp/src/V2/TransactionsClient.cs @@ -0,0 +1,268 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Tesote.Sdk.Internal; +using Tesote.Sdk.Models; + +namespace Tesote.Sdk.V2; + +/// +/// v2 Transactions resource. Per-account list/export/sync, plus the cross-account +/// lookup, bulk fetch, and search endpoints. +/// +public sealed class TransactionsClient +{ + private readonly Transport _transport; + + internal TransactionsClient(Transport transport) + { + _transport = transport; + } + + /// Filter parameters accepted by the v2 transactions list/search endpoints. + public sealed class ListFilters + { + /// ISO8601 date — earliest tesote-imported date to include. + public string? StartDate { get; set; } + /// ISO8601 date — latest tesote-imported date to include. + public string? EndDate { get; set; } + /// Server-side scope name (per docs). + public string? Scope { get; set; } + /// 1-indexed page number. + public int? Page { get; set; } + /// Page size, default 50, max 100. + public int? PerPage { get; set; } + /// Cursor — fetch transactions after this id. + public string? TransactionsAfterId { get; set; } + /// Cursor — fetch transactions before this id. + public string? TransactionsBeforeId { get; set; } + /// ISO8601 — transaction-date floor. + public string? TransactionDateAfter { get; set; } + /// ISO8601 — transaction-date ceiling. + public string? TransactionDateBefore { get; set; } + /// ISO8601 — server-side created_at floor. + public string? CreatedAfter { get; set; } + /// ISO8601 — server-side updated_at floor. + public string? UpdatedAfter { get; set; } + /// Numeric — minimum amount (signed). + public decimal? AmountMin { get; set; } + /// Numeric — maximum amount. + public decimal? AmountMax { get; set; } + /// Numeric — exact amount match. + public decimal? Amount { get; set; } + /// Status filter (e.g. posted, pending). + public string? Status { get; set; } + /// Filter by category id. + public string? CategoryId { get; set; } + /// Filter by counterparty id. + public string? CounterpartyId { get; set; } + /// Search string (description / counterparty name). + public string? Q { get; set; } + /// Type filter (per docs). + public string? Type { get; set; } + /// Reference-code filter. + public string? ReferenceCode { get; set; } + + internal IReadOnlyDictionary? Build() + { + return new QueryBuilder() + .Add("start_date", StartDate) + .Add("end_date", EndDate) + .Add("scope", Scope) + .Add("page", Page) + .Add("per_page", PerPage) + .Add("transactions_after_id", TransactionsAfterId) + .Add("transactions_before_id", TransactionsBeforeId) + .Add("transaction_date_after", TransactionDateAfter) + .Add("transaction_date_before", TransactionDateBefore) + .Add("created_after", CreatedAfter) + .Add("updated_after", UpdatedAfter) + .Add("amount_min", AmountMin) + .Add("amount_max", AmountMax) + .Add("amount", Amount) + .Add("status", Status) + .Add("category_id", CategoryId) + .Add("counterparty_id", CounterpartyId) + .Add("q", Q) + .Add("type", Type) + .Add("reference_code", ReferenceCode) + .BuildOrNull(); + } + } + + /// List transactions for an account with the full v2 filter surface. + public async Task ListAsync( + string accountId, + ListFilters? filters = null, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(accountId); + var path = "/v2/accounts/" + Uri.EscapeDataString(accountId) + "/transactions"; + var opts = RequestOptions.Get(path); + opts.Query = (filters ?? new ListFilters()).Build(); + opts.CacheTtl = TimeSpan.FromMinutes(1); + var node = await _transport.RequestAsync(opts, ct).ConfigureAwait(false); + return Json.Deserialize(node); + } + + /// Export transactions as CSV or JSON file. Returns raw bytes plus content-type. + public Task ExportAsync( + string accountId, + string format = "csv", + ListFilters? filters = null, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(accountId); + ArgumentException.ThrowIfNullOrEmpty(format); + var path = "/v2/accounts/" + Uri.EscapeDataString(accountId) + "/transactions/export"; + var query = (filters ?? new ListFilters()).Build(); + var qb = new QueryBuilder().Add("format", format); + if (query is not null) + { + foreach (var kv in query) + { + qb.Add(kv.Key, kv.Value); + } + } + var opts = RequestOptions.Get(path); + opts.Query = qb.BuildOrNull(); + return _transport.RequestRawAsync(opts, ct); + } + + /// Sync request body for POST /v2/.../transactions/sync. + public sealed class SyncRequest + { + /// Number of transactions to fetch (1..1000). + public int? Count { get; set; } + /// Opaque cursor or the literal "now". + public string? Cursor { get; set; } + /// Optional flags accepted by the API. + public SyncOptions? Options { get; set; } + } + + /// Optional flag bag for . + public sealed class SyncOptions + { + /// Include per-transaction running balances when the workspace allows it. + public bool? IncludeRunningBalance { get; set; } + } + + /// Cursor-based incremental sync for a single account. + public async Task SyncAsync( + string accountId, + SyncRequest request, + string? idempotencyKey = null, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(accountId); + ArgumentNullException.ThrowIfNull(request); + var path = "/v2/accounts/" + Uri.EscapeDataString(accountId) + "/transactions/sync"; + var body = BuildSyncBody(request); + var opts = Requests.Json("POST", path, body, idempotencyKey); + var node = await _transport.RequestAsync(opts, ct).ConfigureAwait(false); + return Json.Deserialize(node); + } + + /// Legacy non-nested sync route kept for backwards compatibility. + public async Task SyncLegacyAsync( + SyncRequest request, + string? idempotencyKey = null, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(request); + var body = BuildSyncBody(request); + var opts = Requests.Json("POST", "/v2/transactions/sync", body, idempotencyKey); + var node = await _transport.RequestAsync(opts, ct).ConfigureAwait(false); + return Json.Deserialize(node); + } + + /// Fetch a single transaction by id (v1-shape payload). + public async Task GetAsync(string transactionId, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(transactionId); + var opts = RequestOptions.Get("/v2/transactions/" + Uri.EscapeDataString(transactionId)); + opts.CacheTtl = TimeSpan.FromMinutes(5); + var node = await _transport.RequestAsync(opts, ct).ConfigureAwait(false); + return Json.Deserialize(node); + } + + /// Bulk-fetch transactions for up to 100 accounts in one call. + public async Task BulkAsync( + IReadOnlyList accountIds, + int? page = null, + int? perPage = null, + int? limit = null, + int? offset = null, + string? idempotencyKey = null, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(accountIds); + var body = new Dictionary + { + ["account_ids"] = accountIds, + ["page"] = page, + ["per_page"] = perPage, + ["limit"] = limit, + ["offset"] = offset, + }; + var opts = Requests.Json("POST", "/v2/transactions/bulk", body, idempotencyKey); + var node = await _transport.RequestAsync(opts, ct).ConfigureAwait(false); + return Json.Deserialize(node); + } + + /// Search transactions across accounts using a substring match plus the standard filter set. + public async Task SearchAsync( + string query, + string? accountId = null, + int? limit = null, + int? offset = null, + ListFilters? filters = null, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(query); + var qb = new QueryBuilder() + .Add("q", query) + .Add("account_id", accountId) + .Add("limit", limit) + .Add("offset", offset); + var extra = (filters ?? new ListFilters()).Build(); + if (extra is not null) + { + foreach (var kv in extra) + { + if (!kv.Key.Equals("q", StringComparison.Ordinal)) + { + qb.Add(kv.Key, kv.Value); + } + } + } + var opts = RequestOptions.Get("/v2/transactions/search"); + opts.Query = qb.BuildOrNull(); + var node = await _transport.RequestAsync(opts, ct).ConfigureAwait(false); + return Json.Deserialize(node); + } + + private static Dictionary BuildSyncBody(SyncRequest request) + { + var body = new Dictionary(); + if (request.Count is not null) + { + body["count"] = request.Count.Value; + } + if (request.Cursor is not null) + { + body["cursor"] = request.Cursor; + } + if (request.Options is not null) + { + var inner = new Dictionary(); + if (request.Options.IncludeRunningBalance is not null) + { + inner["include_running_balance"] = request.Options.IncludeRunningBalance.Value; + } + body["options"] = inner; + } + return body; + } +} diff --git a/packages/csharp/src/V2/V2Client.cs b/packages/csharp/src/V2/V2Client.cs index 923c406..27765be 100644 --- a/packages/csharp/src/V2/V2Client.cs +++ b/packages/csharp/src/V2/V2Client.cs @@ -5,43 +5,49 @@ namespace Tesote.Sdk.V2; /// /// v2 client. Adds writes for payments + sync orchestration on top of v1. -/// -/// 0.1.0 ships transport plumbing plus list/get; -/// other resources stub with until wired. /// public sealed class V2Client : IAsyncDisposable, IDisposable { /// The transport instance, exposed for advanced users. public Transport Transport { get; } - /// Accounts resource client. + /// Status + whoami resource client. + public StatusClient Status { get; } + + /// Accounts resource client (list, get, sync). public AccountsClient Accounts { get; } + /// Transactions resource client (list, export, sync, bulk, search, get). + public TransactionsClient Transactions { get; } + + /// Sync sessions resource client. + public SyncSessionsClient SyncSessions { get; } + + /// Transaction orders resource client. + public TransactionOrdersClient TransactionOrders { get; } + + /// Batches resource client. + public BatchesClient Batches { get; } + + /// Payment methods resource client. + public PaymentMethodsClient PaymentMethods { get; } + /// Construct a v2 client from . public V2Client(ClientOptions options) { ArgumentNullException.ThrowIfNull(options); Transport = new Transport(options); + Status = new StatusClient(Transport); Accounts = new AccountsClient(Transport); + Transactions = new TransactionsClient(Transport); + SyncSessions = new SyncSessionsClient(Transport); + TransactionOrders = new TransactionOrdersClient(Transport); + Batches = new BatchesClient(Transport); + PaymentMethods = new PaymentMethodsClient(Transport); } - /// Status endpoint — not implemented in 0.1.0. - public Task StatusAsync() => throw new NotImplementedException("not implemented"); - - /// Transactions endpoint — not implemented in 0.1.0. - public Task TransactionsAsync() => throw new NotImplementedException("not implemented"); - - /// Sync sessions endpoint — not implemented in 0.1.0. - public Task SyncSessionsAsync() => throw new NotImplementedException("not implemented"); - - /// Transaction orders endpoint — not implemented in 0.1.0. - public Task TransactionOrdersAsync() => throw new NotImplementedException("not implemented"); - - /// Batches endpoint — not implemented in 0.1.0. - public Task BatchesAsync() => throw new NotImplementedException("not implemented"); - - /// Payment methods endpoint — not implemented in 0.1.0. - public Task PaymentMethodsAsync() => throw new NotImplementedException("not implemented"); + /// Last captured rate-limit snapshot. + public RateLimitSnapshot LastRateLimit => Transport.LastRateLimit; /// Async dispose. public ValueTask DisposeAsync() diff --git a/packages/csharp/tests/AccountsTests.cs b/packages/csharp/tests/AccountsTests.cs new file mode 100644 index 0000000..4540be6 --- /dev/null +++ b/packages/csharp/tests/AccountsTests.cs @@ -0,0 +1,120 @@ +using System.Threading.Tasks; +using Tesote.Sdk.Errors; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace Tesote.Sdk.Tests; + +public sealed class AccountsTests : System.IDisposable +{ + private readonly WireMockServer _server; + + public AccountsTests() + { + _server = WireMockServer.Start(); + } + + public void Dispose() + { + _server.Stop(); + _server.Dispose(); + } + + private const string AccountJson = + "{\"id\":\"acc_1\",\"name\":\"Checking\"," + + "\"data\":{\"masked_account_number\":\"****1234\",\"currency\":\"VES\"," + + "\"transactions_data_current_as_of\":null,\"balance_data_current_as_of\":null," + + "\"custom_user_provided_identifier\":null,\"balance_cents\":\"1000\"," + + "\"available_balance_cents\":\"950\"}," + + "\"bank\":{\"name\":\"Banesco\"}," + + "\"legal_entity\":{\"id\":null,\"legal_name\":null}," + + "\"tesote_created_at\":\"2026-01-01T00:00:00Z\"," + + "\"tesote_updated_at\":\"2026-04-01T00:00:00Z\"}"; + + [Fact] + public async Task V1ListReturnsTypedAccounts() + { + _server + .Given(Request.Create().WithPath("/api/v1/accounts").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithBody("{\"total\":1,\"accounts\":[" + AccountJson + "]," + + "\"pagination\":{\"current_page\":1,\"per_page\":50," + + "\"total_pages\":1,\"total_count\":1}}")); + + using var client = TestHelpers.NewV1(_server.Url + "/api"); + var result = await client.Accounts.ListAsync(page: 1, perPage: 50); + Assert.Equal(1, result.Total); + Assert.Equal("acc_1", result.Accounts[0].Id); + Assert.Equal("Banesco", result.Accounts[0].Bank.Name); + Assert.Equal("1000", result.Accounts[0].Data.BalanceCents); + } + + [Fact] + public async Task V2GetReturnsTypedAccount() + { + _server + .Given(Request.Create().WithPath("/api/v2/accounts/acc_1").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200).WithBody(AccountJson)); + + using var client = TestHelpers.NewV2(_server.Url + "/api"); + var account = await client.Accounts.GetAsync("acc_1"); + Assert.Equal("acc_1", account.Id); + Assert.Equal("Checking", account.Name); + } + + [Fact] + public async Task V2GetMaps404ToAccountNotFound() + { + _server + .Given(Request.Create().WithPath("/api/v2/accounts/missing").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(404) + .WithBody("{\"error\":\"missing\",\"error_code\":\"ACCOUNT_NOT_FOUND\"}")); + + using var client = TestHelpers.NewV2(_server.Url + "/api"); + await Assert.ThrowsAsync(() => client.Accounts.GetAsync("missing")); + } + + [Fact] + public async Task V2WorkspaceSuspendedMapsTo403() + { + _server + .Given(Request.Create().WithPath("/api/v2/accounts").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(403) + .WithBody("{\"error\":\"suspended\",\"error_code\":\"WORKSPACE_SUSPENDED\"}")); + + using var client = TestHelpers.NewV2(_server.Url + "/api"); + await Assert.ThrowsAsync(() => client.Accounts.ListAsync()); + } + + [Fact] + public async Task V2SyncReturnsSessionAndCarriesIdempotencyKey() + { + _server + .Given(Request.Create().WithPath("/api/v2/accounts/acc_1/sync").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(202) + .WithBody("{\"message\":\"Sync started\",\"sync_session_id\":\"ss_1\"," + + "\"status\":\"pending\",\"started_at\":\"2026-04-01T00:00:00Z\"}")); + + using var client = TestHelpers.NewV2(_server.Url + "/api"); + var resp = await client.Accounts.SyncAsync("acc_1", idempotencyKey: "abc-123"); + Assert.Equal("ss_1", resp.SyncSessionId); + Assert.Equal("pending", resp.Status); + + var entry = Assert.Single(_server.LogEntries); + Assert.Equal("abc-123", System.Linq.Enumerable.First(entry.RequestMessage.Headers!["Idempotency-Key"])); + } + + [Fact] + public async Task V2SyncMaps409ToSyncInProgress() + { + _server + .Given(Request.Create().WithPath("/api/v2/accounts/acc_1/sync").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(409) + .WithBody("{\"error\":\"in progress\",\"error_code\":\"SYNC_IN_PROGRESS\"}")); + + using var client = TestHelpers.NewV2(_server.Url + "/api"); + await Assert.ThrowsAsync(() => client.Accounts.SyncAsync("acc_1")); + } +} diff --git a/packages/csharp/tests/BatchesTests.cs b/packages/csharp/tests/BatchesTests.cs new file mode 100644 index 0000000..1c95631 --- /dev/null +++ b/packages/csharp/tests/BatchesTests.cs @@ -0,0 +1,134 @@ +using System.Threading.Tasks; +using Tesote.Sdk.Errors; +using Tesote.Sdk.V2; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace Tesote.Sdk.Tests; + +public sealed class BatchesTests : System.IDisposable +{ + private readonly WireMockServer _server; + + public BatchesTests() + { + _server = WireMockServer.Start(); + } + + public void Dispose() + { + _server.Stop(); + _server.Dispose(); + } + + [Fact] + public async Task CreateReturnsOrdersAndBatchId() + { + _server + .Given(Request.Create().WithPath("/api/v2/accounts/acc_1/batches").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(201) + .WithBody("{\"batch_id\":\"b_1\",\"orders\":[],\"errors\":[]}")); + + using var client = TestHelpers.NewV2(_server.Url + "/api"); + var orders = new[] + { + new BatchesClient.BatchOrderInput + { + DestinationPaymentMethodId = "pm_d", + Amount = "10.00", + Currency = "VES", + Description = "fee", + }, + }; + var resp = await client.Batches.CreateAsync("acc_1", orders); + Assert.Equal("b_1", resp.BatchId); + Assert.Empty(resp.Errors); + } + + [Fact] + public async Task CreateMapsBatchValidationError() + { + _server + .Given(Request.Create().WithPath("/api/v2/accounts/acc_1/batches").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(400) + .WithBody("{\"error\":\"bad batch\",\"error_code\":\"BATCH_VALIDATION_ERROR\"}")); + + using var client = TestHelpers.NewV2(_server.Url + "/api"); + var ex = await Assert.ThrowsAsync(() => + client.Batches.CreateAsync("acc_1", System.Array.Empty())); + // why: subclass of ValidationException so callers can catch the parent. + Assert.IsAssignableFrom(ex); + } + + [Fact] + public async Task GetReturnsSummary() + { + _server + .Given(Request.Create().WithPath("/api/v2/accounts/acc_1/batches/b_1").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithBody("{\"batch_id\":\"b_1\",\"total_orders\":3,\"total_amount_cents\":3000," + + "\"amount_currency\":\"VES\"," + + "\"statuses\":{\"draft\":3,\"pending_approval\":0,\"approved\":0," + + "\"processing\":0,\"completed\":0,\"failed\":0,\"cancelled\":0}," + + "\"batch_status\":\"draft\",\"created_at\":\"2026-04-01T00:00:00Z\"," + + "\"orders\":[]}")); + + using var client = TestHelpers.NewV2(_server.Url + "/api"); + var summary = await client.Batches.GetAsync("acc_1", "b_1"); + Assert.Equal("b_1", summary.BatchId); + Assert.Equal(3, summary.Statuses.Draft); + } + + [Fact] + public async Task GetMaps404ToBatchNotFound() + { + _server + .Given(Request.Create().WithPath("/api/v2/accounts/acc_1/batches/missing").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(404) + .WithBody("{\"error\":\"missing\",\"error_code\":\"BATCH_NOT_FOUND\"}")); + + using var client = TestHelpers.NewV2(_server.Url + "/api"); + await Assert.ThrowsAsync(() => client.Batches.GetAsync("acc_1", "missing")); + } + + [Fact] + public async Task ApproveReturnsCounts() + { + _server + .Given(Request.Create().WithPath("/api/v2/accounts/acc_1/batches/b_1/approve").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200).WithBody("{\"approved\":5,\"failed\":0}")); + + using var client = TestHelpers.NewV2(_server.Url + "/api"); + var resp = await client.Batches.ApproveAsync("acc_1", "b_1"); + Assert.Equal(5, resp.Approved); + } + + [Fact] + public async Task SubmitReturnsCounts() + { + _server + .Given(Request.Create().WithPath("/api/v2/accounts/acc_1/batches/b_1/submit").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200).WithBody("{\"enqueued\":4,\"failed\":1}")); + + using var client = TestHelpers.NewV2(_server.Url + "/api"); + var resp = await client.Batches.SubmitAsync("acc_1", "b_1", token: "otp"); + Assert.Equal(4, resp.Enqueued); + Assert.Equal(1, resp.Failed); + } + + [Fact] + public async Task CancelReturnsCounts() + { + _server + .Given(Request.Create().WithPath("/api/v2/accounts/acc_1/batches/b_1/cancel").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithBody("{\"cancelled\":3,\"skipped\":1,\"errors\":[]}")); + + using var client = TestHelpers.NewV2(_server.Url + "/api"); + var resp = await client.Batches.CancelAsync("acc_1", "b_1"); + Assert.Equal(3, resp.Cancelled); + Assert.Equal(1, resp.Skipped); + } +} diff --git a/packages/csharp/tests/PaymentMethodsTests.cs b/packages/csharp/tests/PaymentMethodsTests.cs new file mode 100644 index 0000000..8b5b6ed --- /dev/null +++ b/packages/csharp/tests/PaymentMethodsTests.cs @@ -0,0 +1,133 @@ +using System.Linq; +using System.Threading.Tasks; +using Tesote.Sdk.Errors; +using Tesote.Sdk.V2; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace Tesote.Sdk.Tests; + +public sealed class PaymentMethodsTests : System.IDisposable +{ + private readonly WireMockServer _server; + + public PaymentMethodsTests() + { + _server = WireMockServer.Start(); + } + + public void Dispose() + { + _server.Stop(); + _server.Dispose(); + } + + private const string PmJson = + "{\"id\":\"pm_1\",\"method_type\":\"bank_account\",\"currency\":\"VES\"," + + "\"label\":null,\"details\":{\"bank_code\":\"0001\",\"account_number\":\"1234\"," + + "\"holder_name\":\"Acme\",\"identification_type\":null,\"identification_number\":null}," + + "\"verified\":true,\"verified_at\":\"2026-04-01T00:00:00Z\"," + + "\"last_used_at\":null,\"counterparty\":{\"id\":\"cp_1\",\"name\":\"Dest\"}," + + "\"tesote_account\":null,\"created_at\":\"2026-04-01T00:00:00Z\"," + + "\"updated_at\":\"2026-04-01T00:00:00Z\"}"; + + [Fact] + public async Task ListSendsFiltersAndDeserializes() + { + _server + .Given(Request.Create().WithPath("/api/v2/payment_methods").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithBody("{\"items\":[" + PmJson + "],\"has_more\":false,\"limit\":50,\"offset\":0}")); + + using var client = TestHelpers.NewV2(_server.Url + "/api"); + var result = await client.PaymentMethods.ListAsync( + limit: 50, methodType: "bank_account", verified: true); + Assert.Single(result.Items); + Assert.Equal("pm_1", result.Items[0].Id); + + var entry = Assert.Single(_server.LogEntries); + Assert.Contains("method_type=bank_account", entry.RequestMessage.Url); + Assert.Contains("verified=true", entry.RequestMessage.Url); + } + + [Fact] + public async Task GetMaps404ToPaymentMethodNotFound() + { + _server + .Given(Request.Create().WithPath("/api/v2/payment_methods/missing").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(404) + .WithBody("{\"error\":\"missing\",\"error_code\":\"PAYMENT_METHOD_NOT_FOUND\"}")); + + using var client = TestHelpers.NewV2(_server.Url + "/api"); + await Assert.ThrowsAsync( + () => client.PaymentMethods.GetAsync("missing")); + } + + [Fact] + public async Task CreateSendsPaymentMethodEnvelopeAndDeserializes() + { + _server + .Given(Request.Create().WithPath("/api/v2/payment_methods").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(201).WithBody(PmJson)); + + using var client = TestHelpers.NewV2(_server.Url + "/api"); + var req = new PaymentMethodsClient.WriteRequest + { + MethodType = "bank_account", + Currency = "VES", + Counterparty = new PaymentMethodsClient.CounterpartyInput { Name = "Dest" }, + Details = new PaymentMethodsClient.DetailsInput + { + BankCode = "0001", + AccountNumber = "1234", + HolderName = "Acme", + }, + }; + var pm = await client.PaymentMethods.CreateAsync(req); + Assert.Equal("pm_1", pm.Id); + + var entry = Assert.Single(_server.LogEntries); + Assert.Contains("payment_method", entry.RequestMessage.Body); + Assert.Contains("bank_account", entry.RequestMessage.Body); + } + + [Fact] + public async Task UpdateUsesPatch() + { + _server + .Given(Request.Create().WithPath("/api/v2/payment_methods/pm_1").UsingPatch()) + .RespondWith(Response.Create().WithStatusCode(200).WithBody(PmJson)); + + using var client = TestHelpers.NewV2(_server.Url + "/api"); + var pm = await client.PaymentMethods.UpdateAsync("pm_1", + new PaymentMethodsClient.WriteRequest { Label = "renamed" }); + Assert.Equal("pm_1", pm.Id); + Assert.Equal("PATCH", _server.LogEntries.Single().RequestMessage.Method); + } + + [Fact] + public async Task DeleteIssuesDeleteAndAcceptsNoContent() + { + _server + .Given(Request.Create().WithPath("/api/v2/payment_methods/pm_1").UsingDelete()) + .RespondWith(Response.Create().WithStatusCode(204)); + + using var client = TestHelpers.NewV2(_server.Url + "/api"); + await client.PaymentMethods.DeleteAsync("pm_1"); + Assert.Equal("DELETE", _server.LogEntries.Single().RequestMessage.Method); + } + + [Fact] + public async Task DeleteMaps409ToValidationException() + { + _server + .Given(Request.Create().WithPath("/api/v2/payment_methods/pm_1").UsingDelete()) + .RespondWith(Response.Create().WithStatusCode(409) + .WithBody("{\"error\":\"in use\",\"error_code\":\"VALIDATION_ERROR\"}")); + + using var client = TestHelpers.NewV2(_server.Url + "/api"); + await Assert.ThrowsAsync(() => client.PaymentMethods.DeleteAsync("pm_1")); + } +} diff --git a/packages/csharp/tests/StatusTests.cs b/packages/csharp/tests/StatusTests.cs new file mode 100644 index 0000000..276b553 --- /dev/null +++ b/packages/csharp/tests/StatusTests.cs @@ -0,0 +1,55 @@ +using System.Threading.Tasks; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace Tesote.Sdk.Tests; + +public sealed class StatusTests : System.IDisposable +{ + private readonly WireMockServer _server; + + public StatusTests() + { + _server = WireMockServer.Start(); + } + + public void Dispose() + { + _server.Stop(); + _server.Dispose(); + } + + [Fact] + public async Task V1StatusReturnsTypedResponse() + { + _server + .Given(Request.Create().WithPath("/api/status").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody("{\"status\":\"ok\",\"authenticated\":false}")); + + using var client = TestHelpers.NewV1(_server.Url + "/api"); + var result = await client.Status.GetAsync(); + Assert.Equal("ok", result.Status); + Assert.False(result.Authenticated); + } + + [Fact] + public async Task V2WhoamiReturnsClientStub() + { + _server + .Given(Request.Create().WithPath("/api/v2/whoami").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody("{\"client\":{\"id\":\"c_1\",\"name\":\"Acme\",\"type\":\"workspace\"}}")); + + using var client = TestHelpers.NewV2(_server.Url + "/api"); + var result = await client.Status.WhoamiAsync(); + Assert.Equal("c_1", result.Client.Id); + Assert.Equal("workspace", result.Client.Type); + } +} diff --git a/packages/csharp/tests/SyncSessionsTests.cs b/packages/csharp/tests/SyncSessionsTests.cs new file mode 100644 index 0000000..31938dd --- /dev/null +++ b/packages/csharp/tests/SyncSessionsTests.cs @@ -0,0 +1,60 @@ +using System.Linq; +using System.Threading.Tasks; +using Tesote.Sdk.Errors; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace Tesote.Sdk.Tests; + +public sealed class SyncSessionsTests : System.IDisposable +{ + private readonly WireMockServer _server; + + public SyncSessionsTests() + { + _server = WireMockServer.Start(); + } + + public void Dispose() + { + _server.Stop(); + _server.Dispose(); + } + + [Fact] + public async Task ListReturnsTypedSessionsWithPagination() + { + _server + .Given(Request.Create().WithPath("/api/v2/accounts/acc_1/sync_sessions").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithBody("{\"sync_sessions\":[" + + "{\"id\":\"ss_1\",\"status\":\"completed\"," + + "\"started_at\":\"2026-04-01T00:00:00Z\",\"completed_at\":\"2026-04-01T00:01:00Z\"," + + "\"transactions_synced\":10,\"accounts_count\":1,\"error\":null,\"performance\":null}]," + + "\"limit\":50,\"offset\":0,\"has_more\":false}")); + + using var client = TestHelpers.NewV2(_server.Url + "/api"); + var result = await client.SyncSessions.ListAsync("acc_1", limit: 50, offset: 0, status: "completed"); + Assert.Single(result.SyncSessions); + Assert.Equal("ss_1", result.SyncSessions[0].Id); + Assert.False(result.HasMore); + + var entry = Assert.Single(_server.LogEntries); + Assert.Contains("status=completed", entry.RequestMessage.Url); + } + + [Fact] + public async Task GetMaps404ToSyncSessionNotFound() + { + _server + .Given(Request.Create().WithPath("/api/v2/accounts/acc_1/sync_sessions/missing").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(404) + .WithBody("{\"error\":\"not found\",\"error_code\":\"SYNC_SESSION_NOT_FOUND\"}")); + + using var client = TestHelpers.NewV2(_server.Url + "/api"); + await Assert.ThrowsAsync( + () => client.SyncSessions.GetAsync("acc_1", "missing")); + } +} diff --git a/packages/csharp/tests/TestHelpers.cs b/packages/csharp/tests/TestHelpers.cs new file mode 100644 index 0000000..1ebdb95 --- /dev/null +++ b/packages/csharp/tests/TestHelpers.cs @@ -0,0 +1,31 @@ +using System; +using Tesote.Sdk; +using Tesote.Sdk.V1; +using Tesote.Sdk.V2; + +namespace Tesote.Sdk.Tests; + +internal static class TestHelpers +{ + public static V1Client NewV1(string baseUrl) + { + return new V1Client(new ClientOptions + { + ApiKey = "sk_test_abcd1234", + BaseUrl = baseUrl, + RequestTimeout = TimeSpan.FromSeconds(2), + RetryPolicy = new RetryPolicy(1, TimeSpan.FromMilliseconds(1), TimeSpan.FromMilliseconds(2), false), + }); + } + + public static V2Client NewV2(string baseUrl) + { + return new V2Client(new ClientOptions + { + ApiKey = "sk_test_abcd1234", + BaseUrl = baseUrl, + RequestTimeout = TimeSpan.FromSeconds(2), + RetryPolicy = new RetryPolicy(1, TimeSpan.FromMilliseconds(1), TimeSpan.FromMilliseconds(2), false), + }); + } +} diff --git a/packages/csharp/tests/TransactionOrdersTests.cs b/packages/csharp/tests/TransactionOrdersTests.cs new file mode 100644 index 0000000..8b4bc41 --- /dev/null +++ b/packages/csharp/tests/TransactionOrdersTests.cs @@ -0,0 +1,131 @@ +using System.Linq; +using System.Threading.Tasks; +using Tesote.Sdk.Errors; +using Tesote.Sdk.Models; +using Tesote.Sdk.V2; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace Tesote.Sdk.Tests; + +public sealed class TransactionOrdersTests : System.IDisposable +{ + private readonly WireMockServer _server; + + public TransactionOrdersTests() + { + _server = WireMockServer.Start(); + } + + public void Dispose() + { + _server.Stop(); + _server.Dispose(); + } + + private const string OrderJson = + "{\"id\":\"to_1\",\"status\":\"draft\",\"amount\":100.50,\"currency\":\"VES\"," + + "\"description\":\"pay\",\"reference\":null,\"external_reference\":null," + + "\"idempotency_key\":null,\"batch_id\":null,\"scheduled_for\":null," + + "\"approved_at\":null,\"submitted_at\":null,\"completed_at\":null," + + "\"failed_at\":null,\"cancelled_at\":null," + + "\"source_account\":{\"id\":\"acc_1\",\"name\":\"Checking\",\"payment_method_id\":\"pm_s\"}," + + "\"destination\":{\"payment_method_id\":\"pm_d\",\"counterparty_id\":\"cp_1\",\"counterparty_name\":\"Dest\"}," + + "\"fee\":null,\"execution_strategy\":null,\"tesote_transaction\":null,\"latest_attempt\":null," + + "\"metadata\":null,\"created_at\":\"2026-04-01T00:00:00Z\",\"updated_at\":\"2026-04-01T00:00:00Z\"}"; + + [Fact] + public async Task ListReturnsOffsetPagination() + { + _server + .Given(Request.Create().WithPath("/api/v2/accounts/acc_1/transaction_orders").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithBody("{\"items\":[" + OrderJson + "],\"has_more\":false,\"limit\":50,\"offset\":0}")); + + using var client = TestHelpers.NewV2(_server.Url + "/api"); + var result = await client.TransactionOrders.ListAsync("acc_1", limit: 50); + Assert.Single(result.Items); + Assert.Equal("to_1", result.Items[0].Id); + Assert.Equal("draft", result.Items[0].Status); + } + + [Fact] + public async Task GetMaps404ToTransactionOrderNotFound() + { + _server + .Given(Request.Create().WithPath("/api/v2/accounts/acc_1/transaction_orders/missing").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(404) + .WithBody("{\"error\":\"missing\",\"error_code\":\"TRANSACTION_ORDER_NOT_FOUND\"}")); + + using var client = TestHelpers.NewV2(_server.Url + "/api"); + await Assert.ThrowsAsync( + () => client.TransactionOrders.GetAsync("acc_1", "missing")); + } + + [Fact] + public async Task CreateSendsTransactionOrderEnvelopeAndIdempotencyKey() + { + _server + .Given(Request.Create().WithPath("/api/v2/accounts/acc_1/transaction_orders").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(201).WithBody(OrderJson)); + + using var client = TestHelpers.NewV2(_server.Url + "/api"); + var req = new TransactionOrdersClient.CreateRequest + { + DestinationPaymentMethodId = "pm_d", + Amount = "100.50", + Currency = "VES", + Description = "pay", + }; + var order = await client.TransactionOrders.CreateAsync("acc_1", req, idempotencyKey: "tk-1"); + Assert.Equal("to_1", order.Id); + + var entry = Assert.Single(_server.LogEntries); + Assert.Contains("transaction_order", entry.RequestMessage.Body); + Assert.Equal("tk-1", entry.RequestMessage.Headers!["Idempotency-Key"].First()); + } + + [Fact] + public async Task CreateMaps400ToValidationException() + { + _server + .Given(Request.Create().WithPath("/api/v2/accounts/acc_1/transaction_orders").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(400) + .WithBody("{\"error\":\"bad amount\",\"error_code\":\"VALIDATION_ERROR\"}")); + + using var client = TestHelpers.NewV2(_server.Url + "/api"); + await Assert.ThrowsAsync(() => + client.TransactionOrders.CreateAsync("acc_1", + new TransactionOrdersClient.CreateRequest())); + } + + [Fact] + public async Task SubmitMaps409ToInvalidOrderState() + { + _server + .Given(Request.Create().WithPath("/api/v2/accounts/acc_1/transaction_orders/to_1/submit").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(409) + .WithBody("{\"error\":\"bad state\",\"error_code\":\"INVALID_ORDER_STATE\"}")); + + using var client = TestHelpers.NewV2(_server.Url + "/api"); + await Assert.ThrowsAsync( + () => client.TransactionOrders.SubmitAsync("acc_1", "to_1", token: "otp-123")); + } + + [Fact] + public async Task CancelReturnsCancelledOrder() + { + var cancelled = OrderJson.Replace("\"status\":\"draft\"", "\"status\":\"cancelled\"") + .Replace("\"cancelled_at\":null", "\"cancelled_at\":\"2026-04-01T00:00:00Z\""); + _server + .Given(Request.Create().WithPath("/api/v2/accounts/acc_1/transaction_orders/to_1/cancel").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200).WithBody(cancelled)); + + using var client = TestHelpers.NewV2(_server.Url + "/api"); + var order = await client.TransactionOrders.CancelAsync("acc_1", "to_1"); + Assert.Equal("cancelled", order.Status); + Assert.NotNull(order.CancelledAt); + } +} diff --git a/packages/csharp/tests/TransactionsTests.cs b/packages/csharp/tests/TransactionsTests.cs new file mode 100644 index 0000000..a7e2621 --- /dev/null +++ b/packages/csharp/tests/TransactionsTests.cs @@ -0,0 +1,213 @@ +using System.Linq; +using System.Threading.Tasks; +using Tesote.Sdk.Errors; +using Tesote.Sdk.V2; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace Tesote.Sdk.Tests; + +public sealed class TransactionsTests : System.IDisposable +{ + private readonly WireMockServer _server; + + public TransactionsTests() + { + _server = WireMockServer.Start(); + } + + public void Dispose() + { + _server.Stop(); + _server.Dispose(); + } + + private const string TxJson = + "{\"id\":\"tx_1\",\"status\":\"posted\"," + + "\"data\":{\"amount_cents\":1000,\"currency\":\"VES\",\"description\":\"hi\"," + + "\"transaction_date\":\"2026-04-01\",\"created_at\":null,\"created_at_date\":null," + + "\"note\":null,\"external_service_id\":null,\"running_balance_cents\":null}," + + "\"tesote_imported_at\":\"2026-04-01T00:00:00Z\"," + + "\"tesote_updated_at\":\"2026-04-01T00:00:00Z\"," + + "\"transaction_categories\":[]," + + "\"counterparty\":{\"name\":\"Acme\"}}"; + + [Fact] + public async Task V1ListTransactionsReturnsCursorPagination() + { + _server + .Given(Request.Create().WithPath("/api/v1/accounts/acc_1/transactions").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithBody("{\"total\":1,\"transactions\":[" + TxJson + "]," + + "\"pagination\":{\"has_more\":false,\"per_page\":50,\"after_id\":\"tx_1\",\"before_id\":\"tx_1\"}}")); + + using var client = TestHelpers.NewV1(_server.Url + "/api"); + var result = await client.Accounts.ListTransactionsAsync("acc_1", perPage: 50); + Assert.Equal(1, result.Total); + Assert.Equal("tx_1", result.Transactions[0].Id); + Assert.Equal(1000, result.Transactions[0].Data.AmountCents); + Assert.False(result.Pagination.HasMore); + } + + [Fact] + public async Task V2ListMaps422ToInvalidDateRange() + { + _server + .Given(Request.Create().WithPath("/api/v2/accounts/acc_1/transactions").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(422) + .WithBody("{\"error\":\"bad date range\",\"error_code\":\"INVALID_DATE_RANGE\"}")); + + using var client = TestHelpers.NewV2(_server.Url + "/api"); + var ex = await Assert.ThrowsAsync( + () => client.Transactions.ListAsync("acc_1")); + Assert.IsAssignableFrom(ex); + } + + [Fact] + public async Task V2GetByIdReturnsV1Schema() + { + _server + .Given(Request.Create().WithPath("/api/v2/transactions/tx_1").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200).WithBody(TxJson)); + + using var client = TestHelpers.NewV2(_server.Url + "/api"); + var tx = await client.Transactions.GetAsync("tx_1"); + Assert.Equal("tx_1", tx.Id); + Assert.Equal("posted", tx.Status); + } + + [Fact] + public async Task V2GetByIdMapsTransactionNotFound() + { + _server + .Given(Request.Create().WithPath("/api/v2/transactions/missing").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(404) + .WithBody("{\"error\":\"missing\",\"error_code\":\"TRANSACTION_NOT_FOUND\"}")); + + using var client = TestHelpers.NewV2(_server.Url + "/api"); + await Assert.ThrowsAsync(() => client.Transactions.GetAsync("missing")); + } + + [Fact] + public async Task V2SyncSendsBodyAndReturnsResult() + { + _server + .Given(Request.Create().WithPath("/api/v2/accounts/acc_1/transactions/sync").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithBody("{\"added\":[],\"modified\":[],\"removed\":[]," + + "\"next_cursor\":\"c2\",\"has_more\":false}")); + + using var client = TestHelpers.NewV2(_server.Url + "/api"); + var req = new TransactionsClient.SyncRequest + { + Count = 100, + Cursor = "now", + Options = new TransactionsClient.SyncOptions { IncludeRunningBalance = true }, + }; + var result = await client.Transactions.SyncAsync("acc_1", req); + Assert.Equal("c2", result.NextCursor); + Assert.False(result.HasMore); + + var entry = Assert.Single(_server.LogEntries); + var body = entry.RequestMessage.Body!; + Assert.Contains("\"count\":100", body); + Assert.Contains("\"cursor\":\"now\"", body); + Assert.Contains("\"include_running_balance\":true", body); + } + + [Fact] + public async Task V2SyncMaps422ToInvalidCount() + { + _server + .Given(Request.Create().WithPath("/api/v2/accounts/acc_1/transactions/sync").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(422) + .WithBody("{\"error\":\"bad count\",\"error_code\":\"INVALID_COUNT\"}")); + + using var client = TestHelpers.NewV2(_server.Url + "/api"); + await Assert.ThrowsAsync(() => + client.Transactions.SyncAsync("acc_1", new TransactionsClient.SyncRequest { Count = 9999 })); + } + + [Fact] + public async Task V2SyncMaps403HistorySyncForbidden() + { + _server + .Given(Request.Create().WithPath("/api/v2/accounts/acc_1/transactions/sync").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(403) + .WithBody("{\"error\":\"too old\",\"error_code\":\"HISTORY_SYNC_FORBIDDEN\"}")); + + using var client = TestHelpers.NewV2(_server.Url + "/api"); + await Assert.ThrowsAsync(() => + client.Transactions.SyncAsync("acc_1", new TransactionsClient.SyncRequest())); + } + + [Fact] + public async Task V2BulkValidatesBodyAndDeserializes() + { + _server + .Given(Request.Create().WithPath("/api/v2/transactions/bulk").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithBody("{\"bulk_results\":[{\"account_id\":\"acc_1\",\"transactions\":[" + TxJson + "]," + + "\"pagination\":{\"has_more\":false,\"per_page\":50,\"after_id\":\"tx_1\",\"before_id\":\"tx_1\"}}]}")); + + using var client = TestHelpers.NewV2(_server.Url + "/api"); + var result = await client.Transactions.BulkAsync(new[] { "acc_1", "acc_2" }); + Assert.Single(result.BulkResults); + Assert.Equal("acc_1", result.BulkResults[0].AccountId); + } + + [Fact] + public async Task V2SearchSendsQueryAndDeserializes() + { + _server + .Given(Request.Create().WithPath("/api/v2/transactions/search").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithBody("{\"transactions\":[" + TxJson + "],\"total\":1}")); + + using var client = TestHelpers.NewV2(_server.Url + "/api"); + var result = await client.Transactions.SearchAsync("hi", limit: 10); + Assert.Equal(1, result.Total); + var entry = Assert.Single(_server.LogEntries); + Assert.Contains("q=hi", entry.RequestMessage.Url); + Assert.Contains("limit=10", entry.RequestMessage.Url); + } + + [Fact] + public async Task V2ExportReturnsRawCsvBytes() + { + const string csv = "Transaction ID,Date\ntx_1,2026-04-01\n"; + _server + .Given(Request.Create().WithPath("/api/v2/accounts/acc_1/transactions/export").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200) + .WithHeader("Content-Type", "text/csv; charset=utf-8") + .WithHeader("Content-Disposition", "attachment; filename=\"transactions_acc_1_2026-04-01.csv\"") + .WithBody(csv)); + + using var client = TestHelpers.NewV2(_server.Url + "/api"); + var raw = await client.Transactions.ExportAsync("acc_1", format: "csv"); + Assert.Contains("text/csv", raw.ContentType); + Assert.Contains("transactions_acc_1", raw.ContentDisposition); + Assert.Equal(csv, System.Text.Encoding.UTF8.GetString(raw.Body)); + } + + [Fact] + public async Task UnsupportedMediaType415ReturnedAsApiException() + { + _server + .Given(Request.Create().WithPath("/api/v2/transactions/bulk").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(415) + .WithBody("{\"error\":\"need json\",\"error_code\":\"UNSUPPORTED_MEDIA_TYPE\"}")); + + using var client = TestHelpers.NewV2(_server.Url + "/api"); + var ex = await Assert.ThrowsAsync(() => + client.Transactions.BulkAsync(new[] { "acc_1" })); + Assert.Equal(415, ex.HttpStatus); + + // why: SDK still sends Content-Type: application/json on POST bodies; we verify here. + var entry = Assert.Single(_server.LogEntries); + var contentType = entry.RequestMessage.Headers!["Content-Type"].First(); + Assert.Contains("application/json", contentType); + } +} From 43b50d22ac5346e5a168c457817aa32dfa1236fb Mon Sep 17 00:00:00 2001 From: sebi Date: Tue, 28 Apr 2026 19:43:33 -0500 Subject: [PATCH 02/10] go: implement full v1+v2 surface (0.2.0) 35 endpoints (6 v1 + 29 v2) wired against the Rails-controller-derived spec. Replaces all ErrNotImplemented stubs with real services on V1Client/V2Client (accounts, transactions, status, plus v2 sync_sessions, transaction_orders, batches, payment_methods). Adds 21 typed errors mapped from error_code, json-tagged structs for every model, raw-bytes path for CSV/JSON export. gofmt/vet clean, go test -race ./... green. Co-Authored-By: Claude Opus 4.7 (1M context) --- go/errors.go | 20 ++ go/errors_api.go | 188 ++++++++++++++++ go/errors_map.go | 46 ++++ go/models.go | 365 +++++++++++++++++++++++++++++++ go/v1/accounts.go | 66 ++++++ go/v1/client.go | 44 +--- go/v1/status.go | 32 +++ go/v1/transactions.go | 77 +++++++ go/v1/v1_test.go | 174 +++++++++++++++ go/v2/accounts.go | 85 +++++++ go/v2/accounts_test.go | 139 ++++++++++++ go/v2/batches.go | 116 ++++++++++ go/v2/batches_test.go | 91 ++++++++ go/v2/client.go | 136 +----------- go/v2/helper_test.go | 25 +++ go/v2/payment_methods.go | 139 ++++++++++++ go/v2/payment_methods_test.go | 124 +++++++++++ go/v2/status.go | 32 +++ go/v2/status_test.go | 48 ++++ go/v2/sync_sessions.go | 61 ++++++ go/v2/sync_sessions_test.go | 45 ++++ go/v2/transaction_orders.go | 158 +++++++++++++ go/v2/transaction_orders_test.go | 102 +++++++++ go/v2/transactions.go | 305 ++++++++++++++++++++++++++ go/v2/transactions_test.go | 205 +++++++++++++++++ go/version.go | 2 +- 26 files changed, 2652 insertions(+), 173 deletions(-) create mode 100644 go/models.go create mode 100644 go/v1/accounts.go create mode 100644 go/v1/status.go create mode 100644 go/v1/transactions.go create mode 100644 go/v1/v1_test.go create mode 100644 go/v2/accounts.go create mode 100644 go/v2/accounts_test.go create mode 100644 go/v2/batches.go create mode 100644 go/v2/batches_test.go create mode 100644 go/v2/helper_test.go create mode 100644 go/v2/payment_methods.go create mode 100644 go/v2/payment_methods_test.go create mode 100644 go/v2/status.go create mode 100644 go/v2/status_test.go create mode 100644 go/v2/sync_sessions.go create mode 100644 go/v2/sync_sessions_test.go create mode 100644 go/v2/transaction_orders.go create mode 100644 go/v2/transaction_orders_test.go create mode 100644 go/v2/transactions.go create mode 100644 go/v2/transactions_test.go diff --git a/go/errors.go b/go/errors.go index 7a2ea4f..aa991cb 100644 --- a/go/errors.go +++ b/go/errors.go @@ -24,6 +24,26 @@ var ( ErrTLS = errors.New("tesote: tls error") ErrConfig = errors.New("tesote: config error") ErrEndpointRemoved = errors.New("tesote: endpoint removed") + ErrAccountNotFound = errors.New("tesote: account not found") + ErrTransactionNotFound = errors.New("tesote: transaction not found") + ErrSyncSessionNotFound = errors.New("tesote: sync session not found") + ErrPaymentMethodNotFound = errors.New("tesote: payment method not found") + ErrTransactionOrderNotFound = errors.New("tesote: transaction order not found") + ErrBatchNotFound = errors.New("tesote: batch not found") + ErrBankConnectionNotFound = errors.New("tesote: bank connection not found") + ErrInvalidCursor = errors.New("tesote: invalid cursor") + ErrInvalidCount = errors.New("tesote: invalid count") + ErrInvalidLimit = errors.New("tesote: invalid limit") + ErrInvalidQuery = errors.New("tesote: invalid query") + ErrMissingDateRange = errors.New("tesote: missing date range") + ErrSyncInProgress = errors.New("tesote: sync in progress") + ErrSyncRateLimitExceeded = errors.New("tesote: sync rate limit exceeded") + ErrBankUnderMaintenance = errors.New("tesote: bank under maintenance") + ErrValidation = errors.New("tesote: validation error") + ErrInvalidOrderState = errors.New("tesote: invalid order state") + ErrBankSubmission = errors.New("tesote: bank submission error") + ErrBatchValidation = errors.New("tesote: batch validation error") + ErrInternal = errors.New("tesote: internal error") ) // RequestSummary is the redacted request snapshot attached to every error. diff --git a/go/errors_api.go b/go/errors_api.go index 2c0bce9..17bed83 100644 --- a/go/errors_api.go +++ b/go/errors_api.go @@ -91,3 +91,191 @@ func (e *ServiceUnavailableError) Is(target error) bool { return target == ErrSe // Unwrap exposes the embedded *APIError. func (e *ServiceUnavailableError) Unwrap() error { return e.APIError } + +// AccountNotFoundError is raised on 404 ACCOUNT_NOT_FOUND. +type AccountNotFoundError struct{ *APIError } + +// Is matches the ErrAccountNotFound sentinel. +func (e *AccountNotFoundError) Is(target error) bool { return target == ErrAccountNotFound } + +// Unwrap exposes the embedded *APIError. +func (e *AccountNotFoundError) Unwrap() error { return e.APIError } + +// TransactionNotFoundError is raised on 404 TRANSACTION_NOT_FOUND. +type TransactionNotFoundError struct{ *APIError } + +// Is matches the ErrTransactionNotFound sentinel. +func (e *TransactionNotFoundError) Is(target error) bool { return target == ErrTransactionNotFound } + +// Unwrap exposes the embedded *APIError. +func (e *TransactionNotFoundError) Unwrap() error { return e.APIError } + +// SyncSessionNotFoundError is raised on 404 SYNC_SESSION_NOT_FOUND. +type SyncSessionNotFoundError struct{ *APIError } + +// Is matches the ErrSyncSessionNotFound sentinel. +func (e *SyncSessionNotFoundError) Is(target error) bool { return target == ErrSyncSessionNotFound } + +// Unwrap exposes the embedded *APIError. +func (e *SyncSessionNotFoundError) Unwrap() error { return e.APIError } + +// PaymentMethodNotFoundError is raised on 404 PAYMENT_METHOD_NOT_FOUND. +type PaymentMethodNotFoundError struct{ *APIError } + +// Is matches the ErrPaymentMethodNotFound sentinel. +func (e *PaymentMethodNotFoundError) Is(target error) bool { + return target == ErrPaymentMethodNotFound +} + +// Unwrap exposes the embedded *APIError. +func (e *PaymentMethodNotFoundError) Unwrap() error { return e.APIError } + +// TransactionOrderNotFoundError is raised on 404 TRANSACTION_ORDER_NOT_FOUND. +type TransactionOrderNotFoundError struct{ *APIError } + +// Is matches the ErrTransactionOrderNotFound sentinel. +func (e *TransactionOrderNotFoundError) Is(target error) bool { + return target == ErrTransactionOrderNotFound +} + +// Unwrap exposes the embedded *APIError. +func (e *TransactionOrderNotFoundError) Unwrap() error { return e.APIError } + +// BatchNotFoundError is raised on 404 BATCH_NOT_FOUND. +type BatchNotFoundError struct{ *APIError } + +// Is matches the ErrBatchNotFound sentinel. +func (e *BatchNotFoundError) Is(target error) bool { return target == ErrBatchNotFound } + +// Unwrap exposes the embedded *APIError. +func (e *BatchNotFoundError) Unwrap() error { return e.APIError } + +// BankConnectionNotFoundError is raised on 404 BANK_CONNECTION_NOT_FOUND. +type BankConnectionNotFoundError struct{ *APIError } + +// Is matches the ErrBankConnectionNotFound sentinel. +func (e *BankConnectionNotFoundError) Is(target error) bool { + return target == ErrBankConnectionNotFound +} + +// Unwrap exposes the embedded *APIError. +func (e *BankConnectionNotFoundError) Unwrap() error { return e.APIError } + +// InvalidCursorError is raised on 422 INVALID_CURSOR. +type InvalidCursorError struct{ *APIError } + +// Is matches the ErrInvalidCursor sentinel. +func (e *InvalidCursorError) Is(target error) bool { return target == ErrInvalidCursor } + +// Unwrap exposes the embedded *APIError. +func (e *InvalidCursorError) Unwrap() error { return e.APIError } + +// InvalidCountError is raised on 422 INVALID_COUNT. +type InvalidCountError struct{ *APIError } + +// Is matches the ErrInvalidCount sentinel. +func (e *InvalidCountError) Is(target error) bool { return target == ErrInvalidCount } + +// Unwrap exposes the embedded *APIError. +func (e *InvalidCountError) Unwrap() error { return e.APIError } + +// InvalidLimitError is raised on 422 INVALID_LIMIT. +type InvalidLimitError struct{ *APIError } + +// Is matches the ErrInvalidLimit sentinel. +func (e *InvalidLimitError) Is(target error) bool { return target == ErrInvalidLimit } + +// Unwrap exposes the embedded *APIError. +func (e *InvalidLimitError) Unwrap() error { return e.APIError } + +// InvalidQueryError is raised on 422 INVALID_QUERY. +type InvalidQueryError struct{ *APIError } + +// Is matches the ErrInvalidQuery sentinel. +func (e *InvalidQueryError) Is(target error) bool { return target == ErrInvalidQuery } + +// Unwrap exposes the embedded *APIError. +func (e *InvalidQueryError) Unwrap() error { return e.APIError } + +// MissingDateRangeError is raised on 422 MISSING_DATE_RANGE. +type MissingDateRangeError struct{ *APIError } + +// Is matches the ErrMissingDateRange sentinel. +func (e *MissingDateRangeError) Is(target error) bool { return target == ErrMissingDateRange } + +// Unwrap exposes the embedded *APIError. +func (e *MissingDateRangeError) Unwrap() error { return e.APIError } + +// SyncInProgressError is raised on 409 SYNC_IN_PROGRESS. +type SyncInProgressError struct{ *APIError } + +// Is matches the ErrSyncInProgress sentinel. +func (e *SyncInProgressError) Is(target error) bool { return target == ErrSyncInProgress } + +// Unwrap exposes the embedded *APIError. +func (e *SyncInProgressError) Unwrap() error { return e.APIError } + +// SyncRateLimitExceededError is raised on 429 SYNC_RATE_LIMIT_EXCEEDED. +type SyncRateLimitExceededError struct{ *APIError } + +// Is matches the ErrSyncRateLimitExceeded sentinel. +func (e *SyncRateLimitExceededError) Is(target error) bool { + return target == ErrSyncRateLimitExceeded +} + +// Unwrap exposes the embedded *APIError. +func (e *SyncRateLimitExceededError) Unwrap() error { return e.APIError } + +// BankUnderMaintenanceError is raised on 503 BANK_UNDER_MAINTENANCE. +type BankUnderMaintenanceError struct{ *APIError } + +// Is matches the ErrBankUnderMaintenance sentinel. +func (e *BankUnderMaintenanceError) Is(target error) bool { return target == ErrBankUnderMaintenance } + +// Unwrap exposes the embedded *APIError. +func (e *BankUnderMaintenanceError) Unwrap() error { return e.APIError } + +// ValidationError is raised on 400 VALIDATION_ERROR. +type ValidationError struct{ *APIError } + +// Is matches the ErrValidation sentinel. +func (e *ValidationError) Is(target error) bool { return target == ErrValidation } + +// Unwrap exposes the embedded *APIError. +func (e *ValidationError) Unwrap() error { return e.APIError } + +// InvalidOrderStateError is raised on 409 INVALID_ORDER_STATE. +type InvalidOrderStateError struct{ *APIError } + +// Is matches the ErrInvalidOrderState sentinel. +func (e *InvalidOrderStateError) Is(target error) bool { return target == ErrInvalidOrderState } + +// Unwrap exposes the embedded *APIError. +func (e *InvalidOrderStateError) Unwrap() error { return e.APIError } + +// BankSubmissionError is raised on 422 BANK_SUBMISSION_ERROR. +type BankSubmissionError struct{ *APIError } + +// Is matches the ErrBankSubmission sentinel. +func (e *BankSubmissionError) Is(target error) bool { return target == ErrBankSubmission } + +// Unwrap exposes the embedded *APIError. +func (e *BankSubmissionError) Unwrap() error { return e.APIError } + +// BatchValidationError is raised on 400 BATCH_VALIDATION_ERROR. +type BatchValidationError struct{ *APIError } + +// Is matches the ErrBatchValidation sentinel. +func (e *BatchValidationError) Is(target error) bool { return target == ErrBatchValidation } + +// Unwrap exposes the embedded *APIError. +func (e *BatchValidationError) Unwrap() error { return e.APIError } + +// InternalError is raised on 500 INTERNAL_ERROR. +type InternalError struct{ *APIError } + +// Is matches the ErrInternal sentinel. +func (e *InternalError) Is(target error) bool { return target == ErrInternal } + +// Unwrap exposes the embedded *APIError. +func (e *InternalError) Unwrap() error { return e.APIError } diff --git a/go/errors_map.go b/go/errors_map.go index 3da234e..3b2991c 100644 --- a/go/errors_map.go +++ b/go/errors_map.go @@ -80,6 +80,46 @@ func wrapTyped(base *APIError) error { return &InvalidDateRangeError{APIError: base} case "RATE_LIMIT_EXCEEDED": return &RateLimitExceededError{APIError: base} + case "ACCOUNT_NOT_FOUND": + return &AccountNotFoundError{APIError: base} + case "TRANSACTION_NOT_FOUND": + return &TransactionNotFoundError{APIError: base} + case "SYNC_SESSION_NOT_FOUND": + return &SyncSessionNotFoundError{APIError: base} + case "PAYMENT_METHOD_NOT_FOUND": + return &PaymentMethodNotFoundError{APIError: base} + case "TRANSACTION_ORDER_NOT_FOUND": + return &TransactionOrderNotFoundError{APIError: base} + case "BATCH_NOT_FOUND": + return &BatchNotFoundError{APIError: base} + case "BANK_CONNECTION_NOT_FOUND": + return &BankConnectionNotFoundError{APIError: base} + case "INVALID_CURSOR": + return &InvalidCursorError{APIError: base} + case "INVALID_COUNT": + return &InvalidCountError{APIError: base} + case "INVALID_LIMIT": + return &InvalidLimitError{APIError: base} + case "INVALID_QUERY": + return &InvalidQueryError{APIError: base} + case "MISSING_DATE_RANGE": + return &MissingDateRangeError{APIError: base} + case "SYNC_IN_PROGRESS": + return &SyncInProgressError{APIError: base} + case "SYNC_RATE_LIMIT_EXCEEDED": + return &SyncRateLimitExceededError{APIError: base} + case "BANK_UNDER_MAINTENANCE": + return &BankUnderMaintenanceError{APIError: base} + case "VALIDATION_ERROR": + return &ValidationError{APIError: base} + case "INVALID_ORDER_STATE": + return &InvalidOrderStateError{APIError: base} + case "BANK_SUBMISSION_ERROR": + return &BankSubmissionError{APIError: base} + case "BATCH_VALIDATION_ERROR": + return &BatchValidationError{APIError: base} + case "INTERNAL_ERROR": + return &InternalError{APIError: base} } switch base.HTTPStatus { case http.StatusUnauthorized: @@ -98,16 +138,22 @@ func wrapTyped(base *APIError) error { func fallbackCode(status int) string { switch status { + case http.StatusBadRequest: + return "VALIDATION_ERROR" case http.StatusUnauthorized: return "UNAUTHORIZED" case http.StatusForbidden: return "FORBIDDEN" + case http.StatusNotFound: + return "NOT_FOUND" case http.StatusConflict: return "MUTATION_CONFLICT" case http.StatusUnprocessableEntity: return "UNPROCESSABLE_CONTENT" case http.StatusTooManyRequests: return "RATE_LIMIT_EXCEEDED" + case http.StatusInternalServerError: + return "INTERNAL_ERROR" case http.StatusServiceUnavailable: return "SERVICE_UNAVAILABLE" } diff --git a/go/models.go b/go/models.go new file mode 100644 index 0000000..0e9d7ca --- /dev/null +++ b/go/models.go @@ -0,0 +1,365 @@ +package tesote + +import "encoding/json" + +// Account represents a bank account in v1 and v2 (identical schema). +type Account struct { + ID string `json:"id"` + Name string `json:"name"` + Data AccountData `json:"data"` + Bank AccountBank `json:"bank"` + LegalEntity AccountLegal `json:"legal_entity"` + TesoteCreatedAt string `json:"tesote_created_at"` + TesoteUpdatedAt string `json:"tesote_updated_at"` +} + +// AccountData holds the per-account metadata block. +type AccountData struct { + MaskedAccountNumber string `json:"masked_account_number"` + Currency string `json:"currency"` + TransactionsDataCurrentAsOf *string `json:"transactions_data_current_as_of"` + BalanceDataCurrentAsOf *string `json:"balance_data_current_as_of"` + CustomUserProvidedIdentifier *string `json:"custom_user_provided_identifier"` + BalanceCents *string `json:"balance_cents,omitempty"` + AvailableBalanceCents *string `json:"available_balance_cents,omitempty"` +} + +// AccountBank is the embedded bank reference. +type AccountBank struct { + Name string `json:"name"` +} + +// AccountLegal is the embedded legal-entity reference. +type AccountLegal struct { + ID *string `json:"id"` + LegalName *string `json:"legal_name"` +} + +// PagePagination is the page-based pagination block (v1 accounts, v2 accounts). +type PagePagination struct { + CurrentPage int `json:"current_page"` + PerPage int `json:"per_page"` + TotalPages int `json:"total_pages"` + TotalCount int `json:"total_count"` +} + +// CursorPagination is the cursor-based pagination block (transactions index). +type CursorPagination struct { + HasMore bool `json:"has_more"` + PerPage int `json:"per_page"` + AfterID string `json:"after_id"` + BeforeID string `json:"before_id"` +} + +// AccountListResponse is the wire shape for GET /accounts. +type AccountListResponse struct { + Total int `json:"total"` + Accounts []Account `json:"accounts"` + Pagination PagePagination `json:"pagination"` +} + +// Transaction is the v1 transaction schema (also returned by GET /v2/transactions/{id}). +type Transaction struct { + ID string `json:"id"` + Status string `json:"status"` + Data TransactionData `json:"data"` + TesoteImportedAt string `json:"tesote_imported_at"` + TesoteUpdatedAt string `json:"tesote_updated_at"` + TransactionCategories []TransactionCategory `json:"transaction_categories"` + Counterparty *Counterparty `json:"counterparty"` +} + +// TransactionData is the inner data block for Transaction. +type TransactionData struct { + AmountCents int64 `json:"amount_cents"` + Currency string `json:"currency"` + Description string `json:"description"` + TransactionDate string `json:"transaction_date"` + CreatedAt *string `json:"created_at"` + CreatedAtDate *string `json:"created_at_date"` + Note *string `json:"note"` + ExternalServiceID *string `json:"external_service_id"` + RunningBalanceCents *int64 `json:"running_balance_cents,omitempty"` +} + +// TransactionCategory tags a transaction with a category. +type TransactionCategory struct { + Name string `json:"name"` + ExternalCategoryCode *string `json:"external_category_code"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// Counterparty is the embedded counterparty reference on a transaction. +type Counterparty struct { + Name string `json:"name"` +} + +// TransactionListResponse is the wire shape for GET .../transactions. +type TransactionListResponse struct { + Total int `json:"total"` + Transactions []Transaction `json:"transactions"` + Pagination CursorPagination `json:"pagination"` +} + +// SyncTransaction is the flattened, Plaid-compatible v2 sync entry. +type SyncTransaction struct { + TransactionID string `json:"transaction_id"` + AccountID string `json:"account_id"` + Amount float64 `json:"amount"` + ISOCurrencyCode string `json:"iso_currency_code"` + UnofficialCurrencyCode string `json:"unofficial_currency_code"` + Date string `json:"date"` + Datetime *string `json:"datetime"` + Name string `json:"name"` + MerchantName *string `json:"merchant_name"` + Pending bool `json:"pending"` + Category []string `json:"category"` + RunningBalanceCents *int64 `json:"running_balance_cents,omitempty"` +} + +// RemovedTransaction is the entry shape in the sync response's "removed" array. +type RemovedTransaction struct { + TransactionID string `json:"transaction_id"` + AccountID string `json:"account_id"` +} + +// TransactionSyncResponse is the wire shape for POST .../transactions/sync. +type TransactionSyncResponse struct { + Added []SyncTransaction `json:"added"` + Modified []SyncTransaction `json:"modified"` + Removed []RemovedTransaction `json:"removed"` + NextCursor *string `json:"next_cursor"` + HasMore bool `json:"has_more"` +} + +// TransactionOrder is the v2 transaction order resource. +type TransactionOrder struct { + ID string `json:"id"` + Status string `json:"status"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` + Description string `json:"description"` + Reference *string `json:"reference"` + ExternalReference *string `json:"external_reference"` + IdempotencyKey *string `json:"idempotency_key"` + BatchID *string `json:"batch_id"` + ScheduledFor *string `json:"scheduled_for"` + ApprovedAt *string `json:"approved_at"` + SubmittedAt *string `json:"submitted_at"` + CompletedAt *string `json:"completed_at"` + FailedAt *string `json:"failed_at"` + CancelledAt *string `json:"cancelled_at"` + SourceAccount OrderSourceAccount `json:"source_account"` + Destination OrderDestination `json:"destination"` + Fee *OrderFee `json:"fee"` + ExecutionStrategy *string `json:"execution_strategy"` + TesoteTransaction *OrderTesoteTransaction `json:"tesote_transaction"` + LatestAttempt *OrderLatestAttempt `json:"latest_attempt"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// OrderSourceAccount is the source-account reference on a TransactionOrder. +type OrderSourceAccount struct { + ID string `json:"id"` + Name string `json:"name"` + PaymentMethodID string `json:"payment_method_id"` +} + +// OrderDestination is the destination reference on a TransactionOrder. +type OrderDestination struct { + PaymentMethodID string `json:"payment_method_id"` + CounterpartyID string `json:"counterparty_id"` + CounterpartyName string `json:"counterparty_name"` +} + +// OrderFee is the optional fee block on a TransactionOrder. +type OrderFee struct { + Amount float64 `json:"amount"` + Currency string `json:"currency"` +} + +// OrderTesoteTransaction links the bank-side transaction once executed. +type OrderTesoteTransaction struct { + ID string `json:"id"` + Status string `json:"status"` +} + +// OrderLatestAttempt is the most-recent execution attempt summary. +type OrderLatestAttempt struct { + ID string `json:"id"` + Status string `json:"status"` + AttemptNumber int `json:"attempt_number"` + ExternalReference *string `json:"external_reference"` + SubmittedAt *string `json:"submitted_at"` + CompletedAt *string `json:"completed_at"` + ErrorCode *string `json:"error_code"` + ErrorMessage *string `json:"error_message"` +} + +// OffsetEnvelope is the standard offset-based list envelope (sync_sessions, orders, methods). +type OffsetEnvelope struct { + HasMore bool `json:"has_more"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +// TransactionOrderListResponse wraps GET .../transaction_orders. +type TransactionOrderListResponse struct { + Items []TransactionOrder `json:"items"` + HasMore bool `json:"has_more"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +// PaymentMethod is the v2 payment method resource. +type PaymentMethod struct { + ID string `json:"id"` + MethodType string `json:"method_type"` + Currency string `json:"currency"` + Label *string `json:"label"` + Details map[string]any `json:"details"` + Verified bool `json:"verified"` + VerifiedAt *string `json:"verified_at"` + LastUsedAt *string `json:"last_used_at"` + Counterparty *PaymentMethodCounterparty `json:"counterparty"` + TesoteAccount *PaymentMethodAccount `json:"tesote_account"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// PaymentMethodCounterparty is the counterparty link on a PaymentMethod. +type PaymentMethodCounterparty struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// PaymentMethodAccount is the tesote_account link on a PaymentMethod. +type PaymentMethodAccount struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// PaymentMethodListResponse wraps GET /v2/payment_methods. +type PaymentMethodListResponse struct { + Items []PaymentMethod `json:"items"` + HasMore bool `json:"has_more"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +// SyncSession is the v2 sync session resource. +type SyncSession struct { + ID string `json:"id"` + Status string `json:"status"` + StartedAt string `json:"started_at"` + CompletedAt *string `json:"completed_at"` + TransactionsSynced int `json:"transactions_synced"` + AccountsCount int `json:"accounts_count"` + Error *SyncSessionError `json:"error"` + Performance *SyncPerformance `json:"performance"` +} + +// SyncSessionError describes a failed sync session. +type SyncSessionError struct { + Type string `json:"type"` + Message string `json:"message"` +} + +// SyncPerformance contains optional performance metrics. +type SyncPerformance struct { + TotalDuration float64 `json:"total_duration"` + ComplexityScore float64 `json:"complexity_score"` + SyncSpeedScore float64 `json:"sync_speed_score"` +} + +// SyncSessionListResponse wraps GET /v2/accounts/{id}/sync_sessions. +type SyncSessionListResponse struct { + SyncSessions []SyncSession `json:"sync_sessions"` + Limit int `json:"limit"` + Offset int `json:"offset"` + HasMore bool `json:"has_more"` +} + +// AccountSyncResponse is the 202 response from POST /v2/accounts/{id}/sync. +type AccountSyncResponse struct { + Message string `json:"message"` + SyncSessionID string `json:"sync_session_id"` + Status string `json:"status"` + StartedAt string `json:"started_at"` +} + +// BulkResultEntry is one account's slice of the bulk transactions response. +type BulkResultEntry struct { + AccountID string `json:"account_id"` + Transactions []Transaction `json:"transactions"` + Pagination CursorPagination `json:"pagination"` +} + +// BulkTransactionsResponse wraps POST /v2/transactions/bulk. +type BulkTransactionsResponse struct { + BulkResults []BulkResultEntry `json:"bulk_results"` +} + +// SearchTransactionsResponse wraps GET /v2/transactions/search. +type SearchTransactionsResponse struct { + Transactions []Transaction `json:"transactions"` + Total int `json:"total"` +} + +// BatchSummary is the response for GET /v2/accounts/{id}/batches/{batch_id}. +type BatchSummary struct { + BatchID string `json:"batch_id"` + TotalOrders int `json:"total_orders"` + TotalAmountCents int64 `json:"total_amount_cents"` + AmountCurrency string `json:"amount_currency"` + Statuses map[string]int `json:"statuses"` + BatchStatus string `json:"batch_status"` + CreatedAt string `json:"created_at"` + Orders []TransactionOrder `json:"orders"` +} + +// BatchCreateResponse wraps POST /v2/accounts/{id}/batches. +type BatchCreateResponse struct { + BatchID string `json:"batch_id"` + Orders []TransactionOrder `json:"orders"` + Errors []json.RawMessage `json:"errors"` +} + +// BatchApproveResponse wraps POST /v2/accounts/{id}/batches/{id}/approve. +type BatchApproveResponse struct { + Approved int `json:"approved"` + Failed int `json:"failed"` +} + +// BatchSubmitResponse wraps POST /v2/accounts/{id}/batches/{id}/submit. +type BatchSubmitResponse struct { + Enqueued int `json:"enqueued"` + Failed int `json:"failed"` +} + +// BatchCancelResponse wraps POST /v2/accounts/{id}/batches/{id}/cancel. +type BatchCancelResponse struct { + Cancelled int `json:"cancelled"` + Skipped int `json:"skipped"` + Errors []json.RawMessage `json:"errors"` +} + +// StatusResponse is the response from GET /status and GET /v2/status. +type StatusResponse struct { + Status string `json:"status"` + Authenticated bool `json:"authenticated"` +} + +// WhoamiResponse is the response from GET /whoami and GET /v2/whoami. +type WhoamiResponse struct { + Client WhoamiClient `json:"client"` +} + +// WhoamiClient identifies the API key's owner. +type WhoamiClient struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` +} diff --git a/go/v1/accounts.go b/go/v1/accounts.go new file mode 100644 index 0000000..1d9b695 --- /dev/null +++ b/go/v1/accounts.go @@ -0,0 +1,66 @@ +package v1 + +import ( + "context" + "strconv" + "time" + + tesote "github.com/tesote/sdk/go" +) + +// AccountsService groups v1 account endpoints. +type AccountsService struct { + client *tesote.Client +} + +// AccountsListOptions tunes GET /v1/accounts. +type AccountsListOptions struct { + Page int + PerPage int + Include string + Sort string +} + +func (o AccountsListOptions) query() map[string]string { + q := map[string]string{} + if o.Page > 0 { + q["page"] = strconv.Itoa(o.Page) + } + if o.PerPage > 0 { + q["per_page"] = strconv.Itoa(o.PerPage) + } + if o.Include != "" { + q["include"] = o.Include + } + if o.Sort != "" { + q["sort"] = o.Sort + } + return q +} + +// List lists accounts. GET /v1/accounts. +func (s *AccountsService) List(ctx context.Context, opts AccountsListOptions) (*tesote.AccountListResponse, error) { + out := &tesote.AccountListResponse{} + _, err := s.client.Do(ctx, "GET", pathPrefix+"/accounts", tesote.RequestOptions{ + Query: opts.query(), + Out: out, + CacheTTL: time.Minute, + }) + if err != nil { + return nil, err + } + return out, nil +} + +// Get fetches a single account. GET /v1/accounts/{id}. +func (s *AccountsService) Get(ctx context.Context, id string) (*tesote.Account, error) { + out := &tesote.Account{} + _, err := s.client.Do(ctx, "GET", pathPrefix+"/accounts/"+id, tesote.RequestOptions{ + Out: out, + CacheTTL: 5 * time.Minute, + }) + if err != nil { + return nil, err + } + return out, nil +} diff --git a/go/v1/client.go b/go/v1/client.go index f4ddd70..9706f9f 100644 --- a/go/v1/client.go +++ b/go/v1/client.go @@ -2,9 +2,6 @@ package v1 import ( - "context" - "errors" - tesote "github.com/tesote/sdk/go" ) @@ -25,47 +22,10 @@ func New(t *tesote.Client) *V1Client { return &V1Client{ transport: t, Accounts: &AccountsService{client: t}, - Transactions: &TransactionsService{}, - Status: &StatusService{}, + Transactions: &TransactionsService{client: t}, + Status: &StatusService{client: t}, } } // Transport exposes the underlying *tesote.Client. func (c *V1Client) Transport() *tesote.Client { return c.transport } - -// ErrNotImplemented is returned by every stub method. -var ErrNotImplemented = errors.New("tesote/v1: not implemented") - -// AccountsService groups v1 account endpoints. -type AccountsService struct { - client *tesote.Client -} - -// List lists accounts. Stub. -func (s *AccountsService) List(_ context.Context) error { return ErrNotImplemented } - -// Get fetches an account. Stub. -func (s *AccountsService) Get(_ context.Context, _ string) error { return ErrNotImplemented } - -// TransactionsService groups v1 transaction endpoints. -type TransactionsService struct{} - -// ListForAccount lists transactions for an account. Stub. -func (TransactionsService) ListForAccount(_ context.Context, _ string) error { - return ErrNotImplemented -} - -// Get fetches a transaction. Stub. -func (TransactionsService) Get(_ context.Context, _ string) error { return ErrNotImplemented } - -// StatusService groups v1 status endpoints. -type StatusService struct{} - -// Status returns API status. Stub. -func (StatusService) Status(_ context.Context) error { return ErrNotImplemented } - -// Whoami returns the API key's identity. Stub. -func (StatusService) Whoami(_ context.Context) error { return ErrNotImplemented } - -// Suppress unused imports when only stubs exist. -var _ = pathPrefix diff --git a/go/v1/status.go b/go/v1/status.go new file mode 100644 index 0000000..06488c8 --- /dev/null +++ b/go/v1/status.go @@ -0,0 +1,32 @@ +package v1 + +import ( + "context" + + tesote "github.com/tesote/sdk/go" +) + +// StatusService groups v1 status endpoints. +type StatusService struct { + client *tesote.Client +} + +// Status returns API status. GET /status (unauthenticated). +func (s *StatusService) Status(ctx context.Context) (*tesote.StatusResponse, error) { + out := &tesote.StatusResponse{} + _, err := s.client.Do(ctx, "GET", "/status", tesote.RequestOptions{Out: out}) + if err != nil { + return nil, err + } + return out, nil +} + +// Whoami returns the API key's identity. GET /whoami. +func (s *StatusService) Whoami(ctx context.Context) (*tesote.WhoamiResponse, error) { + out := &tesote.WhoamiResponse{} + _, err := s.client.Do(ctx, "GET", "/whoami", tesote.RequestOptions{Out: out}) + if err != nil { + return nil, err + } + return out, nil +} diff --git a/go/v1/transactions.go b/go/v1/transactions.go new file mode 100644 index 0000000..5f76b3f --- /dev/null +++ b/go/v1/transactions.go @@ -0,0 +1,77 @@ +package v1 + +import ( + "context" + "strconv" + "time" + + tesote "github.com/tesote/sdk/go" +) + +// TransactionsService groups v1 transaction endpoints. +type TransactionsService struct { + client *tesote.Client +} + +// TransactionsListOptions tunes GET /v1/accounts/{id}/transactions. +type TransactionsListOptions struct { + StartDate string + EndDate string + Scope string + Page int + PerPage int + TransactionsAfterID string + TransactionsBeforeID string +} + +func (o TransactionsListOptions) query() map[string]string { + q := map[string]string{} + if o.StartDate != "" { + q["start_date"] = o.StartDate + } + if o.EndDate != "" { + q["end_date"] = o.EndDate + } + if o.Scope != "" { + q["scope"] = o.Scope + } + if o.Page > 0 { + q["page"] = strconv.Itoa(o.Page) + } + if o.PerPage > 0 { + q["per_page"] = strconv.Itoa(o.PerPage) + } + if o.TransactionsAfterID != "" { + q["transactions_after_id"] = o.TransactionsAfterID + } + if o.TransactionsBeforeID != "" { + q["transactions_before_id"] = o.TransactionsBeforeID + } + return q +} + +// ListForAccount lists transactions for an account. GET /v1/accounts/{id}/transactions. +func (s *TransactionsService) ListForAccount(ctx context.Context, accountID string, opts TransactionsListOptions) (*tesote.TransactionListResponse, error) { + out := &tesote.TransactionListResponse{} + _, err := s.client.Do(ctx, "GET", pathPrefix+"/accounts/"+accountID+"/transactions", tesote.RequestOptions{ + Query: opts.query(), + Out: out, + }) + if err != nil { + return nil, err + } + return out, nil +} + +// Get fetches a single transaction. GET /v1/transactions/{id}. +func (s *TransactionsService) Get(ctx context.Context, id string) (*tesote.Transaction, error) { + out := &tesote.Transaction{} + _, err := s.client.Do(ctx, "GET", pathPrefix+"/transactions/"+id, tesote.RequestOptions{ + Out: out, + CacheTTL: 5 * time.Minute, + }) + if err != nil { + return nil, err + } + return out, nil +} diff --git a/go/v1/v1_test.go b/go/v1/v1_test.go new file mode 100644 index 0000000..788140f --- /dev/null +++ b/go/v1/v1_test.go @@ -0,0 +1,174 @@ +package v1_test + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + tesote "github.com/tesote/sdk/go" + "github.com/tesote/sdk/go/v1" +) + +// newClient builds a test transport pinned to the given httptest.Server. +func newClient(t *testing.T, srv *httptest.Server) *tesote.Client { + t.Helper() + c, err := tesote.NewClient(tesote.Options{ + APIKey: "secret-key", + BaseURL: srv.URL, + Sleep: func(_ context.Context, _ time.Duration) error { return nil }, + RandUint63: func() uint64 { return 0 }, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + return c +} + +func TestStatus_Status(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/status" { + t.Errorf("path = %q, want /status", r.URL.Path) + } + _, _ = io.WriteString(w, `{"status":"ok","authenticated":false}`) + })) + defer srv.Close() + + c := v1.New(newClient(t, srv)) + out, err := c.Status.Status(context.Background()) + if err != nil { + t.Fatalf("Status: %v", err) + } + if out.Status != "ok" { + t.Errorf("status = %q", out.Status) + } +} + +func TestStatus_Whoami(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/whoami" { + t.Errorf("path = %q", r.URL.Path) + } + _, _ = io.WriteString(w, `{"client":{"id":"abc","name":"acme","type":"workspace"}}`) + })) + defer srv.Close() + + c := v1.New(newClient(t, srv)) + out, err := c.Status.Whoami(context.Background()) + if err != nil { + t.Fatalf("Whoami: %v", err) + } + if out.Client.ID != "abc" || out.Client.Type != "workspace" { + t.Errorf("client = %+v", out.Client) + } +} + +func TestAccounts_List_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/accounts" { + t.Errorf("path = %q", r.URL.Path) + } + if r.URL.Query().Get("page") != "2" || r.URL.Query().Get("per_page") != "10" { + t.Errorf("query = %q", r.URL.RawQuery) + } + _, _ = io.WriteString(w, `{"total":1,"accounts":[{"id":"a-1","name":"checking","data":{"masked_account_number":"1234","currency":"VES"},"bank":{"name":"BoA"},"legal_entity":{"id":null,"legal_name":null},"tesote_created_at":"2026-04-01T00:00:00Z","tesote_updated_at":"2026-04-01T00:00:00Z"}],"pagination":{"current_page":2,"per_page":10,"total_pages":3,"total_count":21}}`) + })) + defer srv.Close() + + c := v1.New(newClient(t, srv)) + out, err := c.Accounts.List(context.Background(), v1.AccountsListOptions{Page: 2, PerPage: 10}) + if err != nil { + t.Fatalf("List: %v", err) + } + if out.Total != 1 || len(out.Accounts) != 1 { + t.Errorf("response = %+v", out) + } + if out.Accounts[0].ID != "a-1" || out.Accounts[0].Bank.Name != "BoA" { + t.Errorf("account = %+v", out.Accounts[0]) + } +} + +func TestAccounts_Get_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = io.WriteString(w, `{"error":"missing","error_code":"ACCOUNT_NOT_FOUND"}`) + })) + defer srv.Close() + + c := v1.New(newClient(t, srv)) + _, err := c.Accounts.Get(context.Background(), "nope") + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, tesote.ErrAccountNotFound) { + t.Errorf("not ErrAccountNotFound: %v", err) + } + var typed *tesote.AccountNotFoundError + if !errors.As(err, &typed) { + t.Errorf("not *AccountNotFoundError: %T", err) + } +} + +func TestTransactions_ListForAccount_CursorPagination(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/accounts/acct-1/transactions" { + t.Errorf("path = %q", r.URL.Path) + } + if r.URL.Query().Get("transactions_after_id") != "tx-9" { + t.Errorf("missing cursor") + } + _, _ = io.WriteString(w, `{"total":2,"transactions":[],"pagination":{"has_more":true,"per_page":50,"after_id":"tx-99","before_id":"tx-50"}}`) + })) + defer srv.Close() + + c := v1.New(newClient(t, srv)) + out, err := c.Transactions.ListForAccount(context.Background(), "acct-1", v1.TransactionsListOptions{TransactionsAfterID: "tx-9"}) + if err != nil { + t.Fatalf("ListForAccount: %v", err) + } + if !out.Pagination.HasMore || out.Pagination.AfterID != "tx-99" { + t.Errorf("pagination = %+v", out.Pagination) + } +} + +func TestTransactions_Get_DateRangeError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = io.WriteString(w, `{"error":"bad","error_code":"INVALID_DATE_RANGE"}`) + })) + defer srv.Close() + + c := v1.New(newClient(t, srv)) + _, err := c.Transactions.Get(context.Background(), "tx-1") + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, tesote.ErrInvalidDateRange) { + t.Errorf("not ErrInvalidDateRange: %v", err) + } +} + +func TestAccounts_AuthRedaction(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = io.WriteString(w, `{"error":"nope","error_code":"UNAUTHORIZED"}`) + })) + defer srv.Close() + + c := v1.New(newClient(t, srv)) + _, err := c.Accounts.List(context.Background(), v1.AccountsListOptions{}) + var apiErr *tesote.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("not *APIError: %T", err) + } + if !strings.HasPrefix(apiErr.RequestSummary.Authorization, "Bearer ") { + t.Errorf("redacted auth = %q", apiErr.RequestSummary.Authorization) + } + if strings.Contains(apiErr.RequestSummary.Authorization, "secret-") { + t.Errorf("auth leaked: %q", apiErr.RequestSummary.Authorization) + } +} diff --git a/go/v2/accounts.go b/go/v2/accounts.go new file mode 100644 index 0000000..f8c227d --- /dev/null +++ b/go/v2/accounts.go @@ -0,0 +1,85 @@ +package v2 + +import ( + "context" + "strconv" + "time" + + tesote "github.com/tesote/sdk/go" +) + +// AccountsService groups v2 account endpoints. +type AccountsService struct { + client *tesote.Client +} + +// AccountsListOptions tunes GET /v2/accounts. +type AccountsListOptions struct { + Page int + PerPage int + Include string + Sort string +} + +func (o AccountsListOptions) query() map[string]string { + q := map[string]string{} + if o.Page > 0 { + q["page"] = strconv.Itoa(o.Page) + } + if o.PerPage > 0 { + q["per_page"] = strconv.Itoa(o.PerPage) + } + if o.Include != "" { + q["include"] = o.Include + } + if o.Sort != "" { + q["sort"] = o.Sort + } + return q +} + +// List lists accounts. GET /v2/accounts. +func (s *AccountsService) List(ctx context.Context, opts AccountsListOptions) (*tesote.AccountListResponse, error) { + out := &tesote.AccountListResponse{} + _, err := s.client.Do(ctx, "GET", pathPrefix+"/accounts", tesote.RequestOptions{ + Query: opts.query(), + Out: out, + CacheTTL: time.Minute, + }) + if err != nil { + return nil, err + } + return out, nil +} + +// Get fetches a single account. GET /v2/accounts/{id}. +func (s *AccountsService) Get(ctx context.Context, id string) (*tesote.Account, error) { + out := &tesote.Account{} + _, err := s.client.Do(ctx, "GET", pathPrefix+"/accounts/"+id, tesote.RequestOptions{ + Out: out, + CacheTTL: 5 * time.Minute, + }) + if err != nil { + return nil, err + } + return out, nil +} + +// AccountSyncOptions controls POST /v2/accounts/{id}/sync. +type AccountSyncOptions struct { + // IdempotencyKey overrides the auto-generated key for the request. + IdempotencyKey string +} + +// Sync triggers an account sync. POST /v2/accounts/{id}/sync. +func (s *AccountsService) Sync(ctx context.Context, id string, opts AccountSyncOptions) (*tesote.AccountSyncResponse, error) { + out := &tesote.AccountSyncResponse{} + _, err := s.client.Do(ctx, "POST", pathPrefix+"/accounts/"+id+"/sync", tesote.RequestOptions{ + IdempotencyKey: opts.IdempotencyKey, + Out: out, + }) + if err != nil { + return nil, err + } + return out, nil +} diff --git a/go/v2/accounts_test.go b/go/v2/accounts_test.go new file mode 100644 index 0000000..e6a7c78 --- /dev/null +++ b/go/v2/accounts_test.go @@ -0,0 +1,139 @@ +package v2_test + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "testing" + + tesote "github.com/tesote/sdk/go" + "github.com/tesote/sdk/go/v2" +) + +func TestAccounts_List_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v2/accounts" { + t.Errorf("path = %q", r.URL.Path) + } + _, _ = io.WriteString(w, `{"total":0,"accounts":[],"pagination":{"current_page":1,"per_page":50,"total_pages":0,"total_count":0}}`) + })) + defer srv.Close() + + c := v2.New(newClient(t, srv)) + _, err := c.Accounts.List(context.Background(), v2.AccountsListOptions{}) + if err != nil { + t.Fatalf("List: %v", err) + } +} + +func TestAccounts_Get_404Typed(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = io.WriteString(w, `{"error":"x","error_code":"ACCOUNT_NOT_FOUND"}`) + })) + defer srv.Close() + + c := v2.New(newClient(t, srv)) + _, err := c.Accounts.Get(context.Background(), "nope") + if !errors.Is(err, tesote.ErrAccountNotFound) { + t.Errorf("err = %v", err) + } +} + +func TestAccounts_Sync_Success(t *testing.T) { + t.Run("success", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method = %s", r.Method) + } + if r.URL.Path != "/v2/accounts/abc/sync" { + t.Errorf("path = %q", r.URL.Path) + } + if r.Header.Get("Idempotency-Key") == "" { + t.Error("missing Idempotency-Key") + } + w.WriteHeader(http.StatusAccepted) + _, _ = io.WriteString(w, `{"message":"Sync started","sync_session_id":"sid","status":"pending","started_at":"2026-04-28T19:21:00Z"}`) + })) + defer srv.Close() + + c := v2.New(newClient(t, srv)) + out, err := c.Accounts.Sync(context.Background(), "abc", v2.AccountSyncOptions{}) + if err != nil { + t.Fatalf("Sync: %v", err) + } + if out.SyncSessionID != "sid" || out.Status != "pending" { + t.Errorf("response = %+v", out) + } + }) + + t.Run("sync in progress -> typed", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = io.WriteString(w, `{"error":"busy","error_code":"SYNC_IN_PROGRESS"}`) + })) + defer srv.Close() + c := v2.New(newClient(t, srv)) + _, err := c.Accounts.Sync(context.Background(), "abc", v2.AccountSyncOptions{}) + if !errors.Is(err, tesote.ErrSyncInProgress) { + t.Errorf("err = %v", err) + } + var typed *tesote.SyncInProgressError + if !errors.As(err, &typed) { + t.Errorf("not *SyncInProgressError: %T", err) + } + }) + + t.Run("sync rate limit -> typed", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Retry-After", "300") + w.WriteHeader(http.StatusTooManyRequests) + _, _ = io.WriteString(w, `{"error":"slow","error_code":"SYNC_RATE_LIMIT_EXCEEDED","retry_after":300}`) + })) + defer srv.Close() + c := v2.New(newClient(t, srv)) + _, err := c.Accounts.Sync(context.Background(), "abc", v2.AccountSyncOptions{}) + var typed *tesote.SyncRateLimitExceededError + if !errors.As(err, &typed) { + t.Fatalf("not *SyncRateLimitExceededError: %T %v", err, err) + } + if typed.RetryAfter != 300 { + t.Errorf("retry_after = %d", typed.RetryAfter) + } + }) + + t.Run("bank under maintenance -> typed", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = io.WriteString(w, `{"error":"down","error_code":"BANK_UNDER_MAINTENANCE"}`) + })) + defer srv.Close() + c := v2.New(newClient(t, srv)) + _, err := c.Accounts.Sync(context.Background(), "abc", v2.AccountSyncOptions{}) + var typed *tesote.BankUnderMaintenanceError + if !errors.As(err, &typed) { + t.Errorf("err = %T %v", err, err) + } + }) +} + +func TestAccounts_Sync_PreservesUserIdempotencyKey(t *testing.T) { + var seen string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = r.Header.Get("Idempotency-Key") + w.WriteHeader(http.StatusAccepted) + _, _ = io.WriteString(w, `{"message":"x","sync_session_id":"y","status":"pending","started_at":"z"}`) + })) + defer srv.Close() + + c := v2.New(newClient(t, srv)) + _, err := c.Accounts.Sync(context.Background(), "abc", v2.AccountSyncOptions{IdempotencyKey: "user-key"}) + if err != nil { + t.Fatalf("Sync: %v", err) + } + if seen != "user-key" { + t.Errorf("idempotency-key = %q", seen) + } +} diff --git a/go/v2/batches.go b/go/v2/batches.go new file mode 100644 index 0000000..5e9c50f --- /dev/null +++ b/go/v2/batches.go @@ -0,0 +1,116 @@ +package v2 + +import ( + "context" + + tesote "github.com/tesote/sdk/go" +) + +// BatchesService groups v2 batch endpoints. +type BatchesService struct { + client *tesote.Client +} + +// BatchOrderInput is one entry in the orders array of a batch create. +type BatchOrderInput struct { + DestinationPaymentMethodID *string `json:"destination_payment_method_id,omitempty"` + Beneficiary *Beneficiary `json:"beneficiary,omitempty"` + Amount string `json:"amount"` + Currency string `json:"currency"` + Description string `json:"description"` + ScheduledFor *string `json:"scheduled_for,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +// BatchCreateOptions is the body for POST /v2/accounts/{id}/batches. +type BatchCreateOptions struct { + Orders []BatchOrderInput + IdempotencyKey string +} + +// Create creates a batch of transaction orders. +// POST /v2/accounts/{id}/batches. +func (s *BatchesService) Create(ctx context.Context, accountID string, opts BatchCreateOptions) (*tesote.BatchCreateResponse, error) { + body := map[string]any{"orders": opts.Orders} + out := &tesote.BatchCreateResponse{} + _, err := s.client.Do(ctx, "POST", pathPrefix+"/accounts/"+accountID+"/batches", tesote.RequestOptions{ + Body: body, + IdempotencyKey: opts.IdempotencyKey, + Out: out, + }) + if err != nil { + return nil, err + } + return out, nil +} + +// Show fetches a batch summary. +// GET /v2/accounts/{id}/batches/{batch_id}. +func (s *BatchesService) Show(ctx context.Context, accountID, batchID string) (*tesote.BatchSummary, error) { + out := &tesote.BatchSummary{} + _, err := s.client.Do(ctx, "GET", pathPrefix+"/accounts/"+accountID+"/batches/"+batchID, tesote.RequestOptions{ + Out: out, + }) + if err != nil { + return nil, err + } + return out, nil +} + +// BatchActionOptions tunes idempotency for batch state transitions. +type BatchActionOptions struct { + IdempotencyKey string +} + +// Approve transitions all draft orders in the batch to pending_approval. +// POST /v2/accounts/{id}/batches/{batch_id}/approve. +func (s *BatchesService) Approve(ctx context.Context, accountID, batchID string, opts BatchActionOptions) (*tesote.BatchApproveResponse, error) { + out := &tesote.BatchApproveResponse{} + _, err := s.client.Do(ctx, "POST", pathPrefix+"/accounts/"+accountID+"/batches/"+batchID+"/approve", tesote.RequestOptions{ + IdempotencyKey: opts.IdempotencyKey, + Out: out, + }) + if err != nil { + return nil, err + } + return out, nil +} + +// BatchSubmitOptions is the body for POST .../batches/{id}/submit. +type BatchSubmitOptions struct { + Token string + IdempotencyKey string +} + +// Submit enqueues all orders in the batch for bank submission. +// POST /v2/accounts/{id}/batches/{batch_id}/submit. +func (s *BatchesService) Submit(ctx context.Context, accountID, batchID string, opts BatchSubmitOptions) (*tesote.BatchSubmitResponse, error) { + body := map[string]any{} + if opts.Token != "" { + body["token"] = opts.Token + } + out := &tesote.BatchSubmitResponse{} + _, err := s.client.Do(ctx, "POST", pathPrefix+"/accounts/"+accountID+"/batches/"+batchID+"/submit", tesote.RequestOptions{ + Body: body, + IdempotencyKey: opts.IdempotencyKey, + Out: out, + }) + if err != nil { + return nil, err + } + return out, nil +} + +// Cancel cancels all eligible orders in the batch. +// POST /v2/accounts/{id}/batches/{batch_id}/cancel. +func (s *BatchesService) Cancel(ctx context.Context, accountID, batchID string, opts BatchActionOptions) (*tesote.BatchCancelResponse, error) { + out := &tesote.BatchCancelResponse{} + _, err := s.client.Do(ctx, "POST", pathPrefix+"/accounts/"+accountID+"/batches/"+batchID+"/cancel", tesote.RequestOptions{ + IdempotencyKey: opts.IdempotencyKey, + Out: out, + }) + if err != nil { + return nil, err + } + return out, nil +} diff --git a/go/v2/batches_test.go b/go/v2/batches_test.go new file mode 100644 index 0000000..16aac7d --- /dev/null +++ b/go/v2/batches_test.go @@ -0,0 +1,91 @@ +package v2_test + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "testing" + + tesote "github.com/tesote/sdk/go" + "github.com/tesote/sdk/go/v2" +) + +func TestBatches_Create(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Idempotency-Key") == "" { + t.Error("missing Idempotency-Key on POST") + } + w.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(w, `{"batch_id":"b1","orders":[],"errors":[]}`) + })) + defer srv.Close() + c := v2.New(newClient(t, srv)) + out, err := c.Batches.Create(context.Background(), "a1", v2.BatchCreateOptions{ + Orders: []v2.BatchOrderInput{{Amount: "10", Currency: "VES", Description: "x"}}, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + if out.BatchID != "b1" { + t.Errorf("batch = %+v", out) + } +} + +func TestBatches_Show_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = io.WriteString(w, `{"error":"x","error_code":"BATCH_NOT_FOUND"}`) + })) + defer srv.Close() + c := v2.New(newClient(t, srv)) + _, err := c.Batches.Show(context.Background(), "a1", "missing") + if !errors.Is(err, tesote.ErrBatchNotFound) { + t.Errorf("err = %v", err) + } +} + +func TestBatches_Approve(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{"approved":3,"failed":0}`) + })) + defer srv.Close() + c := v2.New(newClient(t, srv)) + out, err := c.Batches.Approve(context.Background(), "a1", "b1", v2.BatchActionOptions{}) + if err != nil { + t.Fatalf("Approve: %v", err) + } + if out.Approved != 3 { + t.Errorf("approved = %d", out.Approved) + } +} + +func TestBatches_Submit_BatchValidationError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, `{"error":"bad","error_code":"BATCH_VALIDATION_ERROR"}`) + })) + defer srv.Close() + c := v2.New(newClient(t, srv)) + _, err := c.Batches.Submit(context.Background(), "a1", "b1", v2.BatchSubmitOptions{Token: "tok"}) + var typed *tesote.BatchValidationError + if !errors.As(err, &typed) { + t.Fatalf("err = %T %v", err, err) + } +} + +func TestBatches_Cancel(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{"cancelled":2,"skipped":1,"errors":[]}`) + })) + defer srv.Close() + c := v2.New(newClient(t, srv)) + out, err := c.Batches.Cancel(context.Background(), "a1", "b1", v2.BatchActionOptions{}) + if err != nil { + t.Fatalf("Cancel: %v", err) + } + if out.Cancelled != 2 || out.Skipped != 1 { + t.Errorf("response = %+v", out) + } +} diff --git a/go/v2/client.go b/go/v2/client.go index bcae7d1..f9d2de0 100644 --- a/go/v2/client.go +++ b/go/v2/client.go @@ -2,9 +2,6 @@ package v2 import ( - "context" - "errors" - tesote "github.com/tesote/sdk/go" ) @@ -29,135 +26,14 @@ func New(t *tesote.Client) *V2Client { return &V2Client{ transport: t, Accounts: &AccountsService{client: t}, - Transactions: &TransactionsService{}, - SyncSessions: &SyncSessionsService{}, - TransactionOrders: &TransactionOrdersService{}, - Batches: &BatchesService{}, - PaymentMethods: &PaymentMethodsService{}, - Status: &StatusService{}, + Transactions: &TransactionsService{client: t}, + SyncSessions: &SyncSessionsService{client: t}, + TransactionOrders: &TransactionOrdersService{client: t}, + Batches: &BatchesService{client: t}, + PaymentMethods: &PaymentMethodsService{client: t}, + Status: &StatusService{client: t}, } } // Transport exposes the underlying *tesote.Client. func (c *V2Client) Transport() *tesote.Client { return c.transport } - -// ErrNotImplemented is returned by every stub method. -var ErrNotImplemented = errors.New("tesote/v2: not implemented") - -// AccountsService groups v2 account endpoints. -type AccountsService struct { - client *tesote.Client -} - -// List lists accounts. Stub. -func (s *AccountsService) List(_ context.Context) error { return ErrNotImplemented } - -// Get fetches an account. Stub. -func (s *AccountsService) Get(_ context.Context, _ string) error { return ErrNotImplemented } - -// Sync triggers an account sync. Stub. -func (s *AccountsService) Sync(_ context.Context, _ string) error { return ErrNotImplemented } - -// TransactionsService groups v2 transaction endpoints. -type TransactionsService struct{} - -// ListForAccount lists transactions for an account. Stub. -func (TransactionsService) ListForAccount(_ context.Context, _ string) error { - return ErrNotImplemented -} - -// Get fetches a transaction. Stub. -func (TransactionsService) Get(_ context.Context, _ string) error { return ErrNotImplemented } - -// Export exports transactions. Stub. -func (TransactionsService) Export(_ context.Context) error { return ErrNotImplemented } - -// Sync triggers a transaction sync. Stub. -func (TransactionsService) Sync(_ context.Context, _ string) error { return ErrNotImplemented } - -// Bulk performs a bulk transaction operation. Stub. -func (TransactionsService) Bulk(_ context.Context) error { return ErrNotImplemented } - -// Search searches transactions. Stub. -func (TransactionsService) Search(_ context.Context) error { return ErrNotImplemented } - -// SyncSessionsService groups v2 sync-session endpoints. -type SyncSessionsService struct{} - -// List lists sync sessions for an account. Stub. -func (SyncSessionsService) List(_ context.Context, _ string) error { return ErrNotImplemented } - -// Get fetches a sync session. Stub. -func (SyncSessionsService) Get(_ context.Context, _ string) error { return ErrNotImplemented } - -// TransactionOrdersService groups v2 transaction-order endpoints. -type TransactionOrdersService struct{} - -// List lists transaction orders. Stub. -func (TransactionOrdersService) List(_ context.Context, _ string) error { return ErrNotImplemented } - -// Get fetches a transaction order. Stub. -func (TransactionOrdersService) Get(_ context.Context, _ string) error { return ErrNotImplemented } - -// Create creates a transaction order. Stub. -func (TransactionOrdersService) Create(_ context.Context, _ string) error { - return ErrNotImplemented -} - -// Submit submits a transaction order. Stub. -func (TransactionOrdersService) Submit(_ context.Context, _ string) error { - return ErrNotImplemented -} - -// Cancel cancels a transaction order. Stub. -func (TransactionOrdersService) Cancel(_ context.Context, _ string) error { - return ErrNotImplemented -} - -// BatchesService groups v2 batch endpoints. -type BatchesService struct{} - -// Create creates a batch. Stub. -func (BatchesService) Create(_ context.Context) error { return ErrNotImplemented } - -// Get fetches a batch. Stub. -func (BatchesService) Get(_ context.Context, _ string) error { return ErrNotImplemented } - -// Approve approves a batch. Stub. -func (BatchesService) Approve(_ context.Context, _ string) error { return ErrNotImplemented } - -// Submit submits a batch. Stub. -func (BatchesService) Submit(_ context.Context, _ string) error { return ErrNotImplemented } - -// Cancel cancels a batch. Stub. -func (BatchesService) Cancel(_ context.Context, _ string) error { return ErrNotImplemented } - -// PaymentMethodsService groups v2 payment-method endpoints. -type PaymentMethodsService struct{} - -// List lists payment methods. Stub. -func (PaymentMethodsService) List(_ context.Context) error { return ErrNotImplemented } - -// Get fetches a payment method. Stub. -func (PaymentMethodsService) Get(_ context.Context, _ string) error { return ErrNotImplemented } - -// Create creates a payment method. Stub. -func (PaymentMethodsService) Create(_ context.Context) error { return ErrNotImplemented } - -// Update updates a payment method. Stub. -func (PaymentMethodsService) Update(_ context.Context, _ string) error { return ErrNotImplemented } - -// Delete deletes a payment method. Stub. -func (PaymentMethodsService) Delete(_ context.Context, _ string) error { return ErrNotImplemented } - -// StatusService groups v2 status endpoints. -type StatusService struct{} - -// Status returns API status. Stub. -func (StatusService) Status(_ context.Context) error { return ErrNotImplemented } - -// Whoami returns the API key identity. Stub. -func (StatusService) Whoami(_ context.Context) error { return ErrNotImplemented } - -// Suppress unused warning until v2 wires endpoints. -var _ = pathPrefix diff --git a/go/v2/helper_test.go b/go/v2/helper_test.go new file mode 100644 index 0000000..a2c2826 --- /dev/null +++ b/go/v2/helper_test.go @@ -0,0 +1,25 @@ +package v2_test + +import ( + "context" + "net/http/httptest" + "testing" + "time" + + tesote "github.com/tesote/sdk/go" +) + +// newClient builds a test transport pinned to the given httptest.Server. +func newClient(t *testing.T, srv *httptest.Server) *tesote.Client { + t.Helper() + c, err := tesote.NewClient(tesote.Options{ + APIKey: "secret-key", + BaseURL: srv.URL, + Sleep: func(_ context.Context, _ time.Duration) error { return nil }, + RandUint63: func() uint64 { return 0 }, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + return c +} diff --git a/go/v2/payment_methods.go b/go/v2/payment_methods.go new file mode 100644 index 0000000..e70dc7c --- /dev/null +++ b/go/v2/payment_methods.go @@ -0,0 +1,139 @@ +package v2 + +import ( + "context" + "strconv" + + tesote "github.com/tesote/sdk/go" +) + +// PaymentMethodsService groups v2 payment-method endpoints. +type PaymentMethodsService struct { + client *tesote.Client +} + +// PaymentMethodsListOptions tunes GET /v2/payment_methods. +type PaymentMethodsListOptions struct { + Limit int + Offset int + MethodType string + Currency string + CounterpartyID string + Verified *bool +} + +func (o PaymentMethodsListOptions) query() map[string]string { + q := map[string]string{} + if o.Limit > 0 { + q["limit"] = strconv.Itoa(o.Limit) + } + if o.Offset > 0 { + q["offset"] = strconv.Itoa(o.Offset) + } + if o.MethodType != "" { + q["method_type"] = o.MethodType + } + if o.Currency != "" { + q["currency"] = o.Currency + } + if o.CounterpartyID != "" { + q["counterparty_id"] = o.CounterpartyID + } + if o.Verified != nil { + if *o.Verified { + q["verified"] = "true" + } else { + q["verified"] = "false" + } + } + return q +} + +// List lists payment methods. GET /v2/payment_methods. +func (s *PaymentMethodsService) List(ctx context.Context, opts PaymentMethodsListOptions) (*tesote.PaymentMethodListResponse, error) { + out := &tesote.PaymentMethodListResponse{} + _, err := s.client.Do(ctx, "GET", pathPrefix+"/payment_methods", tesote.RequestOptions{ + Query: opts.query(), + Out: out, + }) + if err != nil { + return nil, err + } + return out, nil +} + +// Get fetches a single payment method. GET /v2/payment_methods/{id}. +func (s *PaymentMethodsService) Get(ctx context.Context, id string) (*tesote.PaymentMethod, error) { + out := &tesote.PaymentMethod{} + _, err := s.client.Do(ctx, "GET", pathPrefix+"/payment_methods/"+id, tesote.RequestOptions{ + Out: out, + }) + if err != nil { + return nil, err + } + return out, nil +} + +// PaymentMethodCounterpartyInput is the inline counterparty block on create. +type PaymentMethodCounterpartyInput struct { + Name string `json:"name"` +} + +// PaymentMethodInput is the body wrapped under "payment_method" for create/update. +type PaymentMethodInput struct { + MethodType string `json:"method_type,omitempty"` + Currency string `json:"currency,omitempty"` + Label *string `json:"label,omitempty"` + CounterpartyID *string `json:"counterparty_id,omitempty"` + Counterparty *PaymentMethodCounterpartyInput `json:"counterparty,omitempty"` + Details map[string]any `json:"details,omitempty"` +} + +// PaymentMethodMutateOptions wraps the body with idempotency control. +type PaymentMethodMutateOptions struct { + Input PaymentMethodInput + IdempotencyKey string +} + +// Create creates a payment method. POST /v2/payment_methods. +func (s *PaymentMethodsService) Create(ctx context.Context, opts PaymentMethodMutateOptions) (*tesote.PaymentMethod, error) { + body := map[string]any{"payment_method": opts.Input} + out := &tesote.PaymentMethod{} + _, err := s.client.Do(ctx, "POST", pathPrefix+"/payment_methods", tesote.RequestOptions{ + Body: body, + IdempotencyKey: opts.IdempotencyKey, + Out: out, + }) + if err != nil { + return nil, err + } + return out, nil +} + +// Update partially updates a payment method. PATCH /v2/payment_methods/{id}. +func (s *PaymentMethodsService) Update(ctx context.Context, id string, opts PaymentMethodMutateOptions) (*tesote.PaymentMethod, error) { + body := map[string]any{"payment_method": opts.Input} + out := &tesote.PaymentMethod{} + _, err := s.client.Do(ctx, "PATCH", pathPrefix+"/payment_methods/"+id, tesote.RequestOptions{ + Body: body, + IdempotencyKey: opts.IdempotencyKey, + Out: out, + }) + if err != nil { + return nil, err + } + return out, nil +} + +// PaymentMethodDeleteOptions tunes idempotency for Delete. +type PaymentMethodDeleteOptions struct { + IdempotencyKey string +} + +// Delete soft-deletes a payment method. DELETE /v2/payment_methods/{id}. +func (s *PaymentMethodsService) Delete(ctx context.Context, id string, opts PaymentMethodDeleteOptions) error { + _, err := s.client.Do(ctx, "DELETE", pathPrefix+"/payment_methods/"+id, tesote.RequestOptions{ + IdempotencyKey: opts.IdempotencyKey, + }) + return err +} diff --git a/go/v2/payment_methods_test.go b/go/v2/payment_methods_test.go new file mode 100644 index 0000000..a593dae --- /dev/null +++ b/go/v2/payment_methods_test.go @@ -0,0 +1,124 @@ +package v2_test + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "testing" + + tesote "github.com/tesote/sdk/go" + "github.com/tesote/sdk/go/v2" +) + +const pmJSON = `{"id":"p1","method_type":"bank_account","currency":"VES","label":null,"details":{},"verified":false,"verified_at":null,"last_used_at":null,"counterparty":null,"tesote_account":null,"created_at":"x","updated_at":"x"}` + +func TestPaymentMethods_List_VerifiedFilter(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("verified") != "true" { + t.Errorf("verified = %q", r.URL.Query().Get("verified")) + } + _, _ = io.WriteString(w, `{"items":[`+pmJSON+`],"has_more":false,"limit":50,"offset":0}`) + })) + defer srv.Close() + c := v2.New(newClient(t, srv)) + verified := true + out, err := c.PaymentMethods.List(context.Background(), v2.PaymentMethodsListOptions{Verified: &verified}) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(out.Items) != 1 { + t.Errorf("items = %+v", out) + } +} + +func TestPaymentMethods_Get(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, pmJSON) + })) + defer srv.Close() + c := v2.New(newClient(t, srv)) + out, err := c.PaymentMethods.Get(context.Background(), "p1") + if err != nil { + t.Fatalf("Get: %v", err) + } + if out.ID != "p1" || out.MethodType != "bank_account" { + t.Errorf("pm = %+v", out) + } +} + +func TestPaymentMethods_Get_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = io.WriteString(w, `{"error":"x","error_code":"PAYMENT_METHOD_NOT_FOUND"}`) + })) + defer srv.Close() + c := v2.New(newClient(t, srv)) + _, err := c.PaymentMethods.Get(context.Background(), "missing") + if !errors.Is(err, tesote.ErrPaymentMethodNotFound) { + t.Errorf("err = %v", err) + } +} + +func TestPaymentMethods_Create_ValidationError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, `{"error":"bad","error_code":"VALIDATION_ERROR"}`) + })) + defer srv.Close() + c := v2.New(newClient(t, srv)) + _, err := c.PaymentMethods.Create(context.Background(), v2.PaymentMethodMutateOptions{ + Input: v2.PaymentMethodInput{MethodType: "bank_account"}, + }) + var typed *tesote.ValidationError + if !errors.As(err, &typed) { + t.Errorf("err = %T %v", err, err) + } +} + +func TestPaymentMethods_Update_PATCH(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + t.Errorf("method = %s", r.Method) + } + _, _ = io.WriteString(w, pmJSON) + })) + defer srv.Close() + c := v2.New(newClient(t, srv)) + label := "primary" + _, err := c.PaymentMethods.Update(context.Background(), "p1", v2.PaymentMethodMutateOptions{ + Input: v2.PaymentMethodInput{Label: &label}, + }) + if err != nil { + t.Fatalf("Update: %v", err) + } +} + +func TestPaymentMethods_Delete_204(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("method = %s", r.Method) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + c := v2.New(newClient(t, srv)) + if err := c.PaymentMethods.Delete(context.Background(), "p1", v2.PaymentMethodDeleteOptions{}); err != nil { + t.Fatalf("Delete: %v", err) + } +} + +func TestPaymentMethods_Delete_InUseConflict(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = io.WriteString(w, `{"error":"in use","error_code":"VALIDATION_ERROR"}`) + })) + defer srv.Close() + c := v2.New(newClient(t, srv)) + err := c.PaymentMethods.Delete(context.Background(), "p1", v2.PaymentMethodDeleteOptions{}) + var typed *tesote.ValidationError + if !errors.As(err, &typed) { + t.Errorf("err = %T %v", err, err) + } +} diff --git a/go/v2/status.go b/go/v2/status.go new file mode 100644 index 0000000..f5c9b5e --- /dev/null +++ b/go/v2/status.go @@ -0,0 +1,32 @@ +package v2 + +import ( + "context" + + tesote "github.com/tesote/sdk/go" +) + +// StatusService groups v2 status endpoints. +type StatusService struct { + client *tesote.Client +} + +// Status returns API status. GET /v2/status (unauthenticated). +func (s *StatusService) Status(ctx context.Context) (*tesote.StatusResponse, error) { + out := &tesote.StatusResponse{} + _, err := s.client.Do(ctx, "GET", pathPrefix+"/status", tesote.RequestOptions{Out: out}) + if err != nil { + return nil, err + } + return out, nil +} + +// Whoami returns the API key's identity. GET /v2/whoami. +func (s *StatusService) Whoami(ctx context.Context) (*tesote.WhoamiResponse, error) { + out := &tesote.WhoamiResponse{} + _, err := s.client.Do(ctx, "GET", pathPrefix+"/whoami", tesote.RequestOptions{Out: out}) + if err != nil { + return nil, err + } + return out, nil +} diff --git a/go/v2/status_test.go b/go/v2/status_test.go new file mode 100644 index 0000000..6900e63 --- /dev/null +++ b/go/v2/status_test.go @@ -0,0 +1,48 @@ +package v2_test + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/tesote/sdk/go/v2" +) + +func TestV2Status_Status(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v2/status" { + t.Errorf("path = %q", r.URL.Path) + } + _, _ = io.WriteString(w, `{"status":"ok","authenticated":false}`) + })) + defer srv.Close() + + c := v2.New(newClient(t, srv)) + out, err := c.Status.Status(context.Background()) + if err != nil { + t.Fatalf("Status: %v", err) + } + if out.Status != "ok" { + t.Errorf("status = %q", out.Status) + } +} + +func TestV2Status_Whoami(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v2/whoami" { + t.Errorf("path = %q", r.URL.Path) + } + _, _ = io.WriteString(w, `{"client":{"id":"u","name":"n","type":"workspace"}}`) + })) + defer srv.Close() + c := v2.New(newClient(t, srv)) + out, err := c.Status.Whoami(context.Background()) + if err != nil { + t.Fatalf("Whoami: %v", err) + } + if out.Client.Type != "workspace" { + t.Errorf("client = %+v", out.Client) + } +} diff --git a/go/v2/sync_sessions.go b/go/v2/sync_sessions.go new file mode 100644 index 0000000..6c1c73e --- /dev/null +++ b/go/v2/sync_sessions.go @@ -0,0 +1,61 @@ +package v2 + +import ( + "context" + "strconv" + + tesote "github.com/tesote/sdk/go" +) + +// SyncSessionsService groups v2 sync-session endpoints. +type SyncSessionsService struct { + client *tesote.Client +} + +// SyncSessionsListOptions tunes GET /v2/accounts/{id}/sync_sessions. +type SyncSessionsListOptions struct { + Limit int + Offset int + Status string +} + +func (o SyncSessionsListOptions) query() map[string]string { + q := map[string]string{} + if o.Limit > 0 { + q["limit"] = strconv.Itoa(o.Limit) + } + if o.Offset > 0 { + q["offset"] = strconv.Itoa(o.Offset) + } + if o.Status != "" { + q["status"] = o.Status + } + return q +} + +// List lists sync sessions for an account. +// GET /v2/accounts/{id}/sync_sessions. +func (s *SyncSessionsService) List(ctx context.Context, accountID string, opts SyncSessionsListOptions) (*tesote.SyncSessionListResponse, error) { + out := &tesote.SyncSessionListResponse{} + _, err := s.client.Do(ctx, "GET", pathPrefix+"/accounts/"+accountID+"/sync_sessions", tesote.RequestOptions{ + Query: opts.query(), + Out: out, + }) + if err != nil { + return nil, err + } + return out, nil +} + +// Get fetches a single sync session. +// GET /v2/accounts/{id}/sync_sessions/{session_id}. +func (s *SyncSessionsService) Get(ctx context.Context, accountID, sessionID string) (*tesote.SyncSession, error) { + out := &tesote.SyncSession{} + _, err := s.client.Do(ctx, "GET", pathPrefix+"/accounts/"+accountID+"/sync_sessions/"+sessionID, tesote.RequestOptions{ + Out: out, + }) + if err != nil { + return nil, err + } + return out, nil +} diff --git a/go/v2/sync_sessions_test.go b/go/v2/sync_sessions_test.go new file mode 100644 index 0000000..45803bf --- /dev/null +++ b/go/v2/sync_sessions_test.go @@ -0,0 +1,45 @@ +package v2_test + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "testing" + + tesote "github.com/tesote/sdk/go" + "github.com/tesote/sdk/go/v2" +) + +func TestSyncSessions_List_Pagination(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if q.Get("limit") != "10" || q.Get("offset") != "20" || q.Get("status") != "completed" { + t.Errorf("query = %q", r.URL.RawQuery) + } + _, _ = io.WriteString(w, `{"sync_sessions":[{"id":"s1","status":"completed","started_at":"x","completed_at":null,"transactions_synced":0,"accounts_count":1,"error":null,"performance":null}],"limit":10,"offset":20,"has_more":true}`) + })) + defer srv.Close() + c := v2.New(newClient(t, srv)) + out, err := c.SyncSessions.List(context.Background(), "a-1", v2.SyncSessionsListOptions{Limit: 10, Offset: 20, Status: "completed"}) + if err != nil { + t.Fatalf("List: %v", err) + } + if !out.HasMore || len(out.SyncSessions) != 1 { + t.Errorf("response = %+v", out) + } +} + +func TestSyncSessions_Get_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = io.WriteString(w, `{"error":"missing","error_code":"SYNC_SESSION_NOT_FOUND"}`) + })) + defer srv.Close() + c := v2.New(newClient(t, srv)) + _, err := c.SyncSessions.Get(context.Background(), "a-1", "nope") + if !errors.Is(err, tesote.ErrSyncSessionNotFound) { + t.Errorf("err = %v", err) + } +} diff --git a/go/v2/transaction_orders.go b/go/v2/transaction_orders.go new file mode 100644 index 0000000..fc6d40f --- /dev/null +++ b/go/v2/transaction_orders.go @@ -0,0 +1,158 @@ +package v2 + +import ( + "context" + "strconv" + + tesote "github.com/tesote/sdk/go" +) + +// TransactionOrdersService groups v2 transaction-order endpoints. +type TransactionOrdersService struct { + client *tesote.Client +} + +// TransactionOrdersListOptions tunes GET /v2/accounts/{id}/transaction_orders. +type TransactionOrdersListOptions struct { + Limit int + Offset int + Status string + CreatedAfter string + CreatedBefore string + BatchID string +} + +func (o TransactionOrdersListOptions) query() map[string]string { + q := map[string]string{} + if o.Limit > 0 { + q["limit"] = strconv.Itoa(o.Limit) + } + if o.Offset > 0 { + q["offset"] = strconv.Itoa(o.Offset) + } + if o.Status != "" { + q["status"] = o.Status + } + if o.CreatedAfter != "" { + q["created_after"] = o.CreatedAfter + } + if o.CreatedBefore != "" { + q["created_before"] = o.CreatedBefore + } + if o.BatchID != "" { + q["batch_id"] = o.BatchID + } + return q +} + +// List lists transaction orders for an account. +// GET /v2/accounts/{id}/transaction_orders. +func (s *TransactionOrdersService) List(ctx context.Context, accountID string, opts TransactionOrdersListOptions) (*tesote.TransactionOrderListResponse, error) { + out := &tesote.TransactionOrderListResponse{} + _, err := s.client.Do(ctx, "GET", pathPrefix+"/accounts/"+accountID+"/transaction_orders", tesote.RequestOptions{ + Query: opts.query(), + Out: out, + }) + if err != nil { + return nil, err + } + return out, nil +} + +// Get fetches a single transaction order. +// GET /v2/accounts/{id}/transaction_orders/{order_id}. +func (s *TransactionOrdersService) Get(ctx context.Context, accountID, orderID string) (*tesote.TransactionOrder, error) { + out := &tesote.TransactionOrder{} + _, err := s.client.Do(ctx, "GET", pathPrefix+"/accounts/"+accountID+"/transaction_orders/"+orderID, tesote.RequestOptions{ + Out: out, + }) + if err != nil { + return nil, err + } + return out, nil +} + +// Beneficiary is the on-the-fly beneficiary block accepted by Create. +type Beneficiary struct { + Name string `json:"name"` + BankCode *string `json:"bank_code,omitempty"` + AccountNumber *string `json:"account_number,omitempty"` + IdentificationType *string `json:"identification_type,omitempty"` + IdentificationNumber *string `json:"identification_number,omitempty"` +} + +// TransactionOrderCreateOptions is the body for POST .../transaction_orders. +type TransactionOrderCreateOptions struct { + DestinationPaymentMethodID *string `json:"destination_payment_method_id,omitempty"` + Beneficiary *Beneficiary `json:"beneficiary,omitempty"` + Amount string `json:"amount"` + Currency string `json:"currency"` + Description string `json:"description"` + ScheduledFor *string `json:"scheduled_for,omitempty"` + IdempotencyKey *string `json:"idempotency_key,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +// Create creates a draft transaction order. +// POST /v2/accounts/{id}/transaction_orders. +func (s *TransactionOrdersService) Create(ctx context.Context, accountID string, opts TransactionOrderCreateOptions) (*tesote.TransactionOrder, error) { + body := map[string]any{"transaction_order": opts} + idem := "" + if opts.IdempotencyKey != nil { + idem = *opts.IdempotencyKey + } + out := &tesote.TransactionOrder{} + _, err := s.client.Do(ctx, "POST", pathPrefix+"/accounts/"+accountID+"/transaction_orders", tesote.RequestOptions{ + Body: body, + IdempotencyKey: idem, + Out: out, + }) + if err != nil { + return nil, err + } + return out, nil +} + +// SubmitOrderOptions is the body for POST .../transaction_orders/{id}/submit. +type SubmitOrderOptions struct { + Token string + IdempotencyKey string +} + +// Submit submits a transaction order for bank execution. +// POST /v2/accounts/{id}/transaction_orders/{order_id}/submit. +func (s *TransactionOrdersService) Submit(ctx context.Context, accountID, orderID string, opts SubmitOrderOptions) (*tesote.TransactionOrder, error) { + body := map[string]any{} + if opts.Token != "" { + body["token"] = opts.Token + } + out := &tesote.TransactionOrder{} + _, err := s.client.Do(ctx, "POST", pathPrefix+"/accounts/"+accountID+"/transaction_orders/"+orderID+"/submit", tesote.RequestOptions{ + Body: body, + IdempotencyKey: opts.IdempotencyKey, + Out: out, + }) + if err != nil { + return nil, err + } + return out, nil +} + +// CancelOrderOptions wraps idempotency tuning for Cancel. +type CancelOrderOptions struct { + IdempotencyKey string +} + +// Cancel cancels a transaction order. +// POST /v2/accounts/{id}/transaction_orders/{order_id}/cancel. +func (s *TransactionOrdersService) Cancel(ctx context.Context, accountID, orderID string, opts CancelOrderOptions) (*tesote.TransactionOrder, error) { + out := &tesote.TransactionOrder{} + _, err := s.client.Do(ctx, "POST", pathPrefix+"/accounts/"+accountID+"/transaction_orders/"+orderID+"/cancel", tesote.RequestOptions{ + IdempotencyKey: opts.IdempotencyKey, + Out: out, + }) + if err != nil { + return nil, err + } + return out, nil +} diff --git a/go/v2/transaction_orders_test.go b/go/v2/transaction_orders_test.go new file mode 100644 index 0000000..69afb1d --- /dev/null +++ b/go/v2/transaction_orders_test.go @@ -0,0 +1,102 @@ +package v2_test + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "testing" + + tesote "github.com/tesote/sdk/go" + "github.com/tesote/sdk/go/v2" +) + +const orderJSON = `{"id":"o1","status":"draft","amount":1000.00,"currency":"VES","description":"x","reference":null,"external_reference":null,"idempotency_key":null,"batch_id":null,"scheduled_for":null,"approved_at":null,"submitted_at":null,"completed_at":null,"failed_at":null,"cancelled_at":null,"source_account":{"id":"a1","name":"checking","payment_method_id":"p1"},"destination":{"payment_method_id":"p2","counterparty_id":"c1","counterparty_name":"acme"},"fee":null,"execution_strategy":null,"tesote_transaction":null,"latest_attempt":null,"created_at":"x","updated_at":"x"}` + +func TestTransactionOrders_List(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("status") != "draft" { + t.Errorf("query = %q", r.URL.RawQuery) + } + _, _ = io.WriteString(w, `{"items":[`+orderJSON+`],"has_more":false,"limit":50,"offset":0}`) + })) + defer srv.Close() + c := v2.New(newClient(t, srv)) + out, err := c.TransactionOrders.List(context.Background(), "a1", v2.TransactionOrdersListOptions{Status: "draft"}) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(out.Items) != 1 || out.Items[0].ID != "o1" { + t.Errorf("items = %+v", out) + } +} + +func TestTransactionOrders_Create_BodyShape(t *testing.T) { + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &body) + w.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(w, orderJSON) + })) + defer srv.Close() + c := v2.New(newClient(t, srv)) + idem := "k-1" + _, err := c.TransactionOrders.Create(context.Background(), "a1", v2.TransactionOrderCreateOptions{ + Amount: "100.00", + Currency: "VES", + Description: "rent", + IdempotencyKey: &idem, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + tx, ok := body["transaction_order"].(map[string]any) + if !ok || tx["amount"] != "100.00" || tx["currency"] != "VES" { + t.Errorf("body = %+v", body) + } +} + +func TestTransactionOrders_Submit_InvalidOrderState(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = io.WriteString(w, `{"error":"bad state","error_code":"INVALID_ORDER_STATE"}`) + })) + defer srv.Close() + c := v2.New(newClient(t, srv)) + _, err := c.TransactionOrders.Submit(context.Background(), "a1", "o1", v2.SubmitOrderOptions{}) + if !errors.Is(err, tesote.ErrInvalidOrderState) { + t.Errorf("err = %v", err) + } +} + +func TestTransactionOrders_Cancel(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v2/accounts/a1/transaction_orders/o1/cancel" { + t.Errorf("path = %q", r.URL.Path) + } + _, _ = io.WriteString(w, orderJSON) + })) + defer srv.Close() + c := v2.New(newClient(t, srv)) + _, err := c.TransactionOrders.Cancel(context.Background(), "a1", "o1", v2.CancelOrderOptions{}) + if err != nil { + t.Fatalf("Cancel: %v", err) + } +} + +func TestTransactionOrders_Get_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = io.WriteString(w, `{"error":"x","error_code":"TRANSACTION_ORDER_NOT_FOUND"}`) + })) + defer srv.Close() + c := v2.New(newClient(t, srv)) + _, err := c.TransactionOrders.Get(context.Background(), "a1", "missing") + var typed *tesote.TransactionOrderNotFoundError + if !errors.As(err, &typed) { + t.Errorf("err = %T %v", err, err) + } +} diff --git a/go/v2/transactions.go b/go/v2/transactions.go new file mode 100644 index 0000000..712a206 --- /dev/null +++ b/go/v2/transactions.go @@ -0,0 +1,305 @@ +package v2 + +import ( + "bytes" + "context" + "fmt" + "strconv" + "time" + + tesote "github.com/tesote/sdk/go" +) + +// TransactionsService groups v2 transaction endpoints. +type TransactionsService struct { + client *tesote.Client +} + +// TransactionsFilter holds the shared filter parameters for v2 transaction queries. +type TransactionsFilter struct { + StartDate string + EndDate string + Scope string + Page int + PerPage int + TransactionsAfterID string + TransactionsBeforeID string + TransactionDateAfter string + TransactionDateBefore string + CreatedAfter string + UpdatedAfter string + AmountMin string + AmountMax string + Amount string + Status string + CategoryID string + CounterpartyID string + Q string + Type string + ReferenceCode string +} + +func (f TransactionsFilter) query() map[string]string { + q := map[string]string{} + set := func(k, v string) { + if v != "" { + q[k] = v + } + } + set("start_date", f.StartDate) + set("end_date", f.EndDate) + set("scope", f.Scope) + if f.Page > 0 { + q["page"] = strconv.Itoa(f.Page) + } + if f.PerPage > 0 { + q["per_page"] = strconv.Itoa(f.PerPage) + } + set("transactions_after_id", f.TransactionsAfterID) + set("transactions_before_id", f.TransactionsBeforeID) + set("transaction_date_after", f.TransactionDateAfter) + set("transaction_date_before", f.TransactionDateBefore) + set("created_after", f.CreatedAfter) + set("updated_after", f.UpdatedAfter) + set("amount_min", f.AmountMin) + set("amount_max", f.AmountMax) + set("amount", f.Amount) + set("status", f.Status) + set("category_id", f.CategoryID) + set("counterparty_id", f.CounterpartyID) + set("q", f.Q) + set("type", f.Type) + set("reference_code", f.ReferenceCode) + return q +} + +// ListForAccount lists transactions for an account. GET /v2/accounts/{id}/transactions. +func (s *TransactionsService) ListForAccount(ctx context.Context, accountID string, filter TransactionsFilter) (*tesote.TransactionListResponse, error) { + out := &tesote.TransactionListResponse{} + _, err := s.client.Do(ctx, "GET", pathPrefix+"/accounts/"+accountID+"/transactions", tesote.RequestOptions{ + Query: filter.query(), + Out: out, + CacheTTL: time.Minute, + }) + if err != nil { + return nil, err + } + return out, nil +} + +// Get fetches a single transaction. GET /v2/transactions/{id}. +func (s *TransactionsService) Get(ctx context.Context, id string) (*tesote.Transaction, error) { + out := &tesote.Transaction{} + _, err := s.client.Do(ctx, "GET", pathPrefix+"/transactions/"+id, tesote.RequestOptions{ + Out: out, + CacheTTL: 5 * time.Minute, + }) + if err != nil { + return nil, err + } + return out, nil +} + +// ExportFormat is the wire enum for /v2/accounts/{id}/transactions/export `format`. +type ExportFormat string + +// Supported export formats. +const ( + ExportFormatCSV ExportFormat = "csv" + ExportFormatJSON ExportFormat = "json" +) + +// ExportOptions tunes GET /v2/accounts/{id}/transactions/export. +type ExportOptions struct { + Filter TransactionsFilter + Format ExportFormat +} + +// ExportResult is the raw export payload (CSV or pretty JSON bytes). +type ExportResult struct { + Body []byte + ContentType string + Filename string +} + +// Export downloads transactions as CSV or JSON. +// GET /v2/accounts/{id}/transactions/export. Returns the raw payload bytes. +func (s *TransactionsService) Export(ctx context.Context, accountID string, opts ExportOptions) (*ExportResult, error) { + q := opts.Filter.query() + if opts.Format != "" { + q["format"] = string(opts.Format) + } + resp, err := s.client.Do(ctx, "GET", pathPrefix+"/accounts/"+accountID+"/transactions/export", tesote.RequestOptions{ + Query: q, + }) + if err != nil { + return nil, err + } + res := &ExportResult{ + Body: append([]byte(nil), resp.Body...), + ContentType: resp.Header.Get("Content-Type"), + } + if cd := resp.Header.Get("Content-Disposition"); cd != "" { + // why: extract filename from Content-Disposition without bringing in mime/multipart. + res.Filename = parseFilename(cd) + } + return res, nil +} + +// parseFilename pulls a filename from a Content-Disposition header (best-effort). +func parseFilename(cd string) string { + const marker = "filename=" + idx := bytes.Index([]byte(cd), []byte(marker)) + if idx < 0 { + return "" + } + rest := cd[idx+len(marker):] + if len(rest) > 0 && rest[0] == '"' { + end := bytes.IndexByte([]byte(rest[1:]), '"') + if end < 0 { + return rest[1:] + } + return rest[1 : 1+end] + } + end := bytes.IndexAny([]byte(rest), "; ") + if end < 0 { + return rest + } + return rest[:end] +} + +// SyncOptions tunes the body of POST /v2/accounts/{id}/transactions/sync (and legacy). +type SyncOptions struct { + Count int + Cursor string + Options SyncSubOptions + IdempotencyKey string +} + +// SyncSubOptions is the nested `options` block on a sync request. +type SyncSubOptions struct { + IncludeRunningBalance bool `json:"include_running_balance,omitempty"` +} + +func (o SyncOptions) body() map[string]any { + body := map[string]any{} + if o.Count > 0 { + body["count"] = o.Count + } + if o.Cursor != "" { + body["cursor"] = o.Cursor + } + if o.Options.IncludeRunningBalance { + body["options"] = o.Options + } + return body +} + +// Sync runs a transaction sync against an account. +// POST /v2/accounts/{id}/transactions/sync. +func (s *TransactionsService) Sync(ctx context.Context, accountID string, opts SyncOptions) (*tesote.TransactionSyncResponse, error) { + out := &tesote.TransactionSyncResponse{} + _, err := s.client.Do(ctx, "POST", pathPrefix+"/accounts/"+accountID+"/transactions/sync", tesote.RequestOptions{ + Body: opts.body(), + IdempotencyKey: opts.IdempotencyKey, + Out: out, + }) + if err != nil { + return nil, err + } + return out, nil +} + +// SyncLegacy hits the non-nested legacy sync path. +// POST /v2/transactions/sync. +func (s *TransactionsService) SyncLegacy(ctx context.Context, opts SyncOptions) (*tesote.TransactionSyncResponse, error) { + out := &tesote.TransactionSyncResponse{} + _, err := s.client.Do(ctx, "POST", pathPrefix+"/transactions/sync", tesote.RequestOptions{ + Body: opts.body(), + IdempotencyKey: opts.IdempotencyKey, + Out: out, + }) + if err != nil { + return nil, err + } + return out, nil +} + +// BulkOptions tunes POST /v2/transactions/bulk. +type BulkOptions struct { + AccountIDs []string + Page int + PerPage int + Limit int + Offset int + IdempotencyKey string +} + +// Bulk fetches transactions for multiple accounts in one call. +// POST /v2/transactions/bulk. +func (s *TransactionsService) Bulk(ctx context.Context, opts BulkOptions) (*tesote.BulkTransactionsResponse, error) { + if len(opts.AccountIDs) == 0 { + return nil, fmt.Errorf("tesote/v2: BulkOptions.AccountIDs must not be empty") + } + body := map[string]any{ + "account_ids": opts.AccountIDs, + } + if opts.Page > 0 { + body["page"] = opts.Page + } + if opts.PerPage > 0 { + body["per_page"] = opts.PerPage + } + if opts.Limit > 0 { + body["limit"] = opts.Limit + } + if opts.Offset > 0 { + body["offset"] = opts.Offset + } + out := &tesote.BulkTransactionsResponse{} + _, err := s.client.Do(ctx, "POST", pathPrefix+"/transactions/bulk", tesote.RequestOptions{ + Body: body, + IdempotencyKey: opts.IdempotencyKey, + Out: out, + }) + if err != nil { + return nil, err + } + return out, nil +} + +// SearchOptions tunes GET /v2/transactions/search. +type SearchOptions struct { + Q string + AccountID string + Limit int + Offset int + Filter TransactionsFilter +} + +// Search runs a text search across transactions. GET /v2/transactions/search. +func (s *TransactionsService) Search(ctx context.Context, opts SearchOptions) (*tesote.SearchTransactionsResponse, error) { + if opts.Q == "" { + return nil, fmt.Errorf("tesote/v2: SearchOptions.Q is required") + } + q := opts.Filter.query() + q["q"] = opts.Q + if opts.AccountID != "" { + q["account_id"] = opts.AccountID + } + if opts.Limit > 0 { + q["limit"] = strconv.Itoa(opts.Limit) + } + if opts.Offset > 0 { + q["offset"] = strconv.Itoa(opts.Offset) + } + out := &tesote.SearchTransactionsResponse{} + _, err := s.client.Do(ctx, "GET", pathPrefix+"/transactions/search", tesote.RequestOptions{ + Query: q, + Out: out, + }) + if err != nil { + return nil, err + } + return out, nil +} diff --git a/go/v2/transactions_test.go b/go/v2/transactions_test.go new file mode 100644 index 0000000..2553b85 --- /dev/null +++ b/go/v2/transactions_test.go @@ -0,0 +1,205 @@ +package v2_test + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + tesote "github.com/tesote/sdk/go" + "github.com/tesote/sdk/go/v2" +) + +func TestTransactions_ListForAccount_FilterQuery(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if q.Get("amount_min") != "10.00" || q.Get("category_id") != "cat-1" { + t.Errorf("query = %q", r.URL.RawQuery) + } + _, _ = io.WriteString(w, `{"total":0,"transactions":[],"pagination":{"has_more":false,"per_page":50,"after_id":"","before_id":""}}`) + })) + defer srv.Close() + c := v2.New(newClient(t, srv)) + _, err := c.Transactions.ListForAccount(context.Background(), "a-1", v2.TransactionsFilter{ + AmountMin: "10.00", + CategoryID: "cat-1", + }) + if err != nil { + t.Fatalf("List: %v", err) + } +} + +func TestTransactions_Get_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{"id":"tx-1","status":"posted","data":{"amount_cents":1000,"currency":"VES","description":"x","transaction_date":"2026-04-01"},"tesote_imported_at":"2026-04-01","tesote_updated_at":"2026-04-01","transaction_categories":[],"counterparty":null}`) + })) + defer srv.Close() + c := v2.New(newClient(t, srv)) + out, err := c.Transactions.Get(context.Background(), "tx-1") + if err != nil { + t.Fatalf("Get: %v", err) + } + if out.ID != "tx-1" || out.Data.AmountCents != 1000 { + t.Errorf("tx = %+v", out) + } +} + +func TestTransactions_Sync_Body(t *testing.T) { + var got map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if ct := r.Header.Get("Content-Type"); ct != "application/json" { + t.Errorf("content-type = %q", ct) + } + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &got) + _, _ = io.WriteString(w, `{"added":[],"modified":[],"removed":[],"next_cursor":null,"has_more":false}`) + })) + defer srv.Close() + c := v2.New(newClient(t, srv)) + _, err := c.Transactions.Sync(context.Background(), "a-1", v2.SyncOptions{Count: 100, Cursor: "now"}) + if err != nil { + t.Fatalf("Sync: %v", err) + } + if got["count"].(float64) != 100 || got["cursor"] != "now" { + t.Errorf("body = %+v", got) + } +} + +func TestTransactions_SyncLegacy_Path(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v2/transactions/sync" { + t.Errorf("path = %q", r.URL.Path) + } + _, _ = io.WriteString(w, `{"added":[],"modified":[],"removed":[],"next_cursor":null,"has_more":false}`) + })) + defer srv.Close() + c := v2.New(newClient(t, srv)) + _, err := c.Transactions.SyncLegacy(context.Background(), v2.SyncOptions{}) + if err != nil { + t.Fatalf("SyncLegacy: %v", err) + } +} + +func TestTransactions_Sync_HistoryForbidden(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = io.WriteString(w, `{"error":"too far back","error_code":"HISTORY_SYNC_FORBIDDEN"}`) + })) + defer srv.Close() + c := v2.New(newClient(t, srv)) + _, err := c.Transactions.Sync(context.Background(), "a-1", v2.SyncOptions{}) + if !errors.Is(err, tesote.ErrHistorySyncForbidden) { + t.Errorf("err = %v", err) + } +} + +func TestTransactions_Sync_InvalidCount(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = io.WriteString(w, `{"error":"x","error_code":"INVALID_COUNT"}`) + })) + defer srv.Close() + c := v2.New(newClient(t, srv)) + _, err := c.Transactions.Sync(context.Background(), "a-1", v2.SyncOptions{Count: 5000}) + var typed *tesote.InvalidCountError + if !errors.As(err, &typed) { + t.Fatalf("err = %T %v", err, err) + } +} + +func TestTransactions_Bulk_RequiresAccountIDs(t *testing.T) { + c := v2.New(newClient(t, httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})))) + _, err := c.Transactions.Bulk(context.Background(), v2.BulkOptions{}) + if err == nil || !strings.Contains(err.Error(), "AccountIDs") { + t.Errorf("err = %v", err) + } +} + +func TestTransactions_Bulk_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v2/transactions/bulk" { + t.Errorf("path = %q", r.URL.Path) + } + _, _ = io.WriteString(w, `{"bulk_results":[{"account_id":"a-1","transactions":[],"pagination":{"has_more":false,"per_page":50,"after_id":"","before_id":""}}]}`) + })) + defer srv.Close() + c := v2.New(newClient(t, srv)) + out, err := c.Transactions.Bulk(context.Background(), v2.BulkOptions{AccountIDs: []string{"a-1"}}) + if err != nil { + t.Fatalf("Bulk: %v", err) + } + if len(out.BulkResults) != 1 || out.BulkResults[0].AccountID != "a-1" { + t.Errorf("bulk = %+v", out) + } +} + +func TestTransactions_Search_RequiresQ(t *testing.T) { + c := v2.New(newClient(t, httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})))) + _, err := c.Transactions.Search(context.Background(), v2.SearchOptions{}) + if err == nil || !strings.Contains(err.Error(), "Q is required") { + t.Errorf("err = %v", err) + } +} + +func TestTransactions_Search_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("q") != "starbucks" { + t.Errorf("q = %q", r.URL.Query().Get("q")) + } + _, _ = io.WriteString(w, `{"transactions":[],"total":0}`) + })) + defer srv.Close() + c := v2.New(newClient(t, srv)) + _, err := c.Transactions.Search(context.Background(), v2.SearchOptions{Q: "starbucks", Limit: 10}) + if err != nil { + t.Fatalf("Search: %v", err) + } +} + +func TestTransactions_Export_CSV(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("format") != "csv" { + t.Errorf("format = %q", r.URL.Query().Get("format")) + } + w.Header().Set("Content-Type", "text/csv") + w.Header().Set("Content-Disposition", `attachment; filename="transactions_a-1_2026-04-28.csv"`) + _, _ = io.WriteString(w, "Transaction ID,Date\nx,y\n") + })) + defer srv.Close() + c := v2.New(newClient(t, srv)) + res, err := c.Transactions.Export(context.Background(), "a-1", v2.ExportOptions{Format: v2.ExportFormatCSV}) + if err != nil { + t.Fatalf("Export: %v", err) + } + if res.ContentType != "text/csv" { + t.Errorf("content-type = %q", res.ContentType) + } + if res.Filename != "transactions_a-1_2026-04-28.csv" { + t.Errorf("filename = %q", res.Filename) + } + if !strings.Contains(string(res.Body), "Transaction ID") { + t.Errorf("body = %q", string(res.Body)) + } +} + +func TestTransactions_Sync_415RequiresContentType(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Content-Type") != "application/json" { + w.WriteHeader(http.StatusUnsupportedMediaType) + _, _ = io.WriteString(w, `{"error":"need json","error_code":"UNPROCESSABLE_CONTENT"}`) + return + } + _, _ = io.WriteString(w, `{"added":[],"modified":[],"removed":[],"next_cursor":null,"has_more":false}`) + })) + defer srv.Close() + c := v2.New(newClient(t, srv)) + // Sync always sends a body, so Content-Type should be set automatically. + _, err := c.Transactions.Sync(context.Background(), "a-1", v2.SyncOptions{Count: 10}) + if err != nil { + t.Fatalf("Sync: %v", err) + } +} diff --git a/go/version.go b/go/version.go index c31d9ec..d6bfff2 100644 --- a/go/version.go +++ b/go/version.go @@ -1,4 +1,4 @@ package tesote // Version is the SDK release version. Bumped per entry in CHANGELOG.md. -const Version = "0.1.1" +const Version = "0.2.0" From 58774e97693242c9ffb42ec7ac02632586f1cdc7 Mon Sep 17 00:00:00 2001 From: sebi Date: Tue, 28 Apr 2026 19:43:41 -0500 Subject: [PATCH 03/10] java: implement full v1+v2 surface (0.2.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 35 endpoints (6 v1 + 29 v2) wired against the Rails-controller-derived spec. First real resource layer for Java — 0.1.x shipped only the builder + transport plumbing. Adds 7 resource clients on V1Client/V2Client (Accounts, Transactions, Status, plus v2 SyncSessions, TransactionOrders, Batches, PaymentMethods), 22 model records with snake_case wire ↔ camelCase Java via @JsonProperty, 25 typed exceptions per error_code, Transport.requestRaw for CSV/JSON export. ./gradlew clean build test green with -Werror -Xlint:all, 88 JUnit tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/java/CHANGELOG.md | 45 +++ packages/java/build.gradle.kts | 2 +- .../main/java/com/tesote/sdk/Transport.java | 125 +++++++- .../sdk/errors/AccountNotFoundException.java | 11 + .../BankConnectionNotFoundException.java | 11 + .../sdk/errors/BankSubmissionException.java | 11 + .../errors/BankUnderMaintenanceException.java | 16 + .../sdk/errors/BatchNotFoundException.java | 11 + .../sdk/errors/BatchValidationException.java | 11 + .../sdk/errors/CategoryNotFoundException.java | 11 + .../errors/CounterpartyNotFoundException.java | 11 + .../tesote/sdk/errors/ErrorDispatcher.java | 72 +++++ .../sdk/errors/InternalErrorException.java | 14 + .../sdk/errors/InvalidCountException.java | 11 + .../sdk/errors/InvalidCursorException.java | 11 + .../sdk/errors/InvalidLimitException.java | 11 + .../errors/InvalidOrderStateException.java | 15 + .../sdk/errors/InvalidQueryException.java | 11 + .../errors/LegalEntityNotFoundException.java | 11 + .../sdk/errors/MissingDateRangeException.java | 11 + .../tesote/sdk/errors/NotFoundException.java | 16 + .../PaymentMethodNotFoundException.java | 11 + .../sdk/errors/SyncInProgressException.java | 16 + .../SyncRateLimitExceededException.java | 15 + .../errors/SyncSessionNotFoundException.java | 11 + .../errors/TransactionNotFoundException.java | 11 + .../TransactionOrderNotFoundException.java | 11 + .../sdk/errors/ValidationException.java | 15 + .../sdk/errors/WebhookNotFoundException.java | 11 + .../java/com/tesote/sdk/internal/Json.java | 32 ++ .../com/tesote/sdk/internal/QueryParams.java | 33 ++ .../java/com/tesote/sdk/models/Account.java | 41 +++ .../sdk/models/AccountSyncResponse.java | 15 + .../com/tesote/sdk/models/AccountsPage.java | 13 + .../sdk/models/BatchActionResponse.java | 22 ++ .../sdk/models/BatchCreateResponse.java | 14 + .../com/tesote/sdk/models/BatchSummary.java | 19 ++ .../com/tesote/sdk/models/BulkResponse.java | 16 + .../tesote/sdk/models/CursorPagination.java | 19 ++ .../com/tesote/sdk/models/OffsetPage.java | 18 ++ .../com/tesote/sdk/models/PagePagination.java | 15 + .../com/tesote/sdk/models/PaymentMethod.java | 34 +++ .../java/com/tesote/sdk/models/Status.java | 10 + .../com/tesote/sdk/models/SyncSession.java | 29 ++ .../tesote/sdk/models/SyncSessionsPage.java | 14 + .../tesote/sdk/models/SyncTransaction.java | 26 ++ .../sdk/models/SyncTransactionsResponse.java | 27 ++ .../com/tesote/sdk/models/Transaction.java | 45 +++ .../tesote/sdk/models/TransactionOrder.java | 77 +++++ .../tesote/sdk/models/TransactionsExport.java | 22 ++ .../tesote/sdk/models/TransactionsPage.java | 13 + .../models/TransactionsSearchResponse.java | 12 + .../java/com/tesote/sdk/models/Whoami.java | 14 + .../com/tesote/sdk/models/package-info.java | 7 + .../com/tesote/sdk/v1/AccountsClient.java | 63 ++++ .../java/com/tesote/sdk/v1/StatusClient.java | 28 ++ .../com/tesote/sdk/v1/TransactionsClient.java | 75 +++++ .../main/java/com/tesote/sdk/v1/V1Client.java | 17 +- .../com/tesote/sdk/v2/AccountsClient.java | 84 +++++ .../java/com/tesote/sdk/v2/BatchesClient.java | 97 ++++++ .../tesote/sdk/v2/PaymentMethodsClient.java | 160 ++++++++++ .../java/com/tesote/sdk/v2/StatusClient.java | 26 ++ .../com/tesote/sdk/v2/SyncSessionsClient.java | 56 ++++ .../sdk/v2/TransactionOrdersClient.java | 181 +++++++++++ .../com/tesote/sdk/v2/TransactionsClient.java | 286 ++++++++++++++++++ .../main/java/com/tesote/sdk/v2/V2Client.java | 32 +- .../test/java/com/tesote/sdk/ErrorsTest.java | 61 ++++ .../tesote/sdk/v1/V1AccountsClientTest.java | 102 +++++++ .../com/tesote/sdk/v1/V1StatusClientTest.java | 57 ++++ .../sdk/v1/V1TransactionsClientTest.java | 100 ++++++ .../tesote/sdk/v2/V2AccountsClientTest.java | 114 +++++++ .../tesote/sdk/v2/V2BatchesClientTest.java | 138 +++++++++ .../sdk/v2/V2PaymentMethodsClientTest.java | 138 +++++++++ .../com/tesote/sdk/v2/V2StatusClientTest.java | 55 ++++ .../sdk/v2/V2SyncSessionsClientTest.java | 72 +++++ .../sdk/v2/V2TransactionOrdersClientTest.java | 161 ++++++++++ .../sdk/v2/V2TransactionsClientTest.java | 198 ++++++++++++ 77 files changed, 3420 insertions(+), 17 deletions(-) create mode 100644 packages/java/src/main/java/com/tesote/sdk/errors/AccountNotFoundException.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/errors/BankConnectionNotFoundException.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/errors/BankSubmissionException.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/errors/BankUnderMaintenanceException.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/errors/BatchNotFoundException.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/errors/BatchValidationException.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/errors/CategoryNotFoundException.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/errors/CounterpartyNotFoundException.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/errors/InternalErrorException.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/errors/InvalidCountException.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/errors/InvalidCursorException.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/errors/InvalidLimitException.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/errors/InvalidOrderStateException.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/errors/InvalidQueryException.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/errors/LegalEntityNotFoundException.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/errors/MissingDateRangeException.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/errors/NotFoundException.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/errors/PaymentMethodNotFoundException.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/errors/SyncInProgressException.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/errors/SyncRateLimitExceededException.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/errors/SyncSessionNotFoundException.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/errors/TransactionNotFoundException.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/errors/TransactionOrderNotFoundException.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/errors/ValidationException.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/errors/WebhookNotFoundException.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/internal/QueryParams.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/models/Account.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/models/AccountSyncResponse.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/models/AccountsPage.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/models/BatchActionResponse.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/models/BatchCreateResponse.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/models/BatchSummary.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/models/BulkResponse.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/models/CursorPagination.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/models/OffsetPage.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/models/PagePagination.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/models/PaymentMethod.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/models/Status.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/models/SyncSession.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/models/SyncSessionsPage.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/models/SyncTransaction.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/models/SyncTransactionsResponse.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/models/Transaction.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/models/TransactionOrder.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/models/TransactionsExport.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/models/TransactionsPage.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/models/TransactionsSearchResponse.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/models/Whoami.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/models/package-info.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/v1/AccountsClient.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/v1/StatusClient.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/v1/TransactionsClient.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/v2/AccountsClient.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/v2/BatchesClient.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/v2/PaymentMethodsClient.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/v2/StatusClient.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/v2/SyncSessionsClient.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/v2/TransactionOrdersClient.java create mode 100644 packages/java/src/main/java/com/tesote/sdk/v2/TransactionsClient.java create mode 100644 packages/java/src/test/java/com/tesote/sdk/v1/V1AccountsClientTest.java create mode 100644 packages/java/src/test/java/com/tesote/sdk/v1/V1StatusClientTest.java create mode 100644 packages/java/src/test/java/com/tesote/sdk/v1/V1TransactionsClientTest.java create mode 100644 packages/java/src/test/java/com/tesote/sdk/v2/V2AccountsClientTest.java create mode 100644 packages/java/src/test/java/com/tesote/sdk/v2/V2BatchesClientTest.java create mode 100644 packages/java/src/test/java/com/tesote/sdk/v2/V2PaymentMethodsClientTest.java create mode 100644 packages/java/src/test/java/com/tesote/sdk/v2/V2StatusClientTest.java create mode 100644 packages/java/src/test/java/com/tesote/sdk/v2/V2SyncSessionsClientTest.java create mode 100644 packages/java/src/test/java/com/tesote/sdk/v2/V2TransactionOrdersClientTest.java create mode 100644 packages/java/src/test/java/com/tesote/sdk/v2/V2TransactionsClientTest.java diff --git a/packages/java/CHANGELOG.md b/packages/java/CHANGELOG.md index ae21b43..d9ca561 100644 --- a/packages/java/CHANGELOG.md +++ b/packages/java/CHANGELOG.md @@ -4,6 +4,51 @@ All notable changes to the `com.tesote:sdk` artifact are documented here. This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 0.2.0 - 2026-04-28 + +### Added + +- Full v1 + v2 resource layer. All 35 endpoints from the controller spec + are implemented; no `UnsupportedOperationException` placeholders remain. +- v1 resource clients: `StatusClient` (status, whoami), `AccountsClient` + (list, get), `TransactionsClient` (listForAccount, get). +- v2 resource clients: `StatusClient`, `AccountsClient` (+ `sync`), + `TransactionsClient` (listForAccount, get, sync, syncLegacy, bulk, + search, export), `SyncSessionsClient`, `TransactionOrdersClient` + (list, get, create, submit, cancel), `BatchesClient` (create, show, + approve, submit, cancel), `PaymentMethodsClient` (list, get, create, + update, delete). +- Java records for every API model: `Account`, `Transaction`, + `SyncTransaction`, `SyncSession`, `TransactionOrder`, `PaymentMethod`, + `BatchSummary`, `Status`, `Whoami`, plus paginated wrappers + (`PagePagination`, `CursorPagination`, `OffsetPage`, + `AccountsPage`, `TransactionsPage`, `SyncSessionsPage`) and request / + response envelopes. +- New typed exceptions, one per API `error_code`: `AccountNotFoundException`, + `TransactionNotFoundException`, `SyncSessionNotFoundException`, + `PaymentMethodNotFoundException`, `TransactionOrderNotFoundException`, + `BatchNotFoundException`, `BankConnectionNotFoundException`, + `CategoryNotFoundException`, `CounterpartyNotFoundException`, + `LegalEntityNotFoundException`, `WebhookNotFoundException` + (all subclasses of new `NotFoundException`); `ValidationException`, + `BatchValidationException`, `BankSubmissionException`; + `InvalidCursorException`, `InvalidCountException`, + `InvalidLimitException`, `InvalidQueryException`, + `MissingDateRangeException`; `InvalidOrderStateException`, + `SyncInProgressException`, `SyncRateLimitExceededException`, + `BankUnderMaintenanceException`, `InternalErrorException`. +- `Transport.requestRaw(...)` for file-download endpoints (CSV / JSON + export); `Transport.Options.jsonBody(Object)` and `query(Map)` helpers. +- Unit tests for every resource client (mocked via `MockWebServer`) plus + expanded error-dispatcher coverage. + +### Changed + +- `Content-Type: application/json` is now sent on every POST/PUT/PATCH, + even when the body is empty, matching the spec's 415 contract. +- `V1Client` / `V2Client` accessors (`accounts()`, `transactions()`, etc.) + now return live resource clients instead of `UnsupportedOperationException`. + ## 0.1.1 - 2026-04-28 ### Changed diff --git a/packages/java/build.gradle.kts b/packages/java/build.gradle.kts index 82fbd68..451929e 100644 --- a/packages/java/build.gradle.kts +++ b/packages/java/build.gradle.kts @@ -9,7 +9,7 @@ plugins { } group = "com.tesote" -version = "0.1.1" +version = "0.2.0" description = "Official Java SDK for the equipo.tesote.com API" java { diff --git a/packages/java/src/main/java/com/tesote/sdk/Transport.java b/packages/java/src/main/java/com/tesote/sdk/Transport.java index d7dd7f6..2b0e609 100644 --- a/packages/java/src/main/java/com/tesote/sdk/Transport.java +++ b/packages/java/src/main/java/com/tesote/sdk/Transport.java @@ -46,11 +46,14 @@ */ public final class Transport { public static final String DEFAULT_BASE_URL = "https://equipo.tesote.com/api"; - public static final String SDK_VERSION = "0.1.0"; + public static final String SDK_VERSION = "0.2.0"; private static final List MUTATING_METHODS = List.of("POST", "PUT", "PATCH", "DELETE"); + private static final List JSON_BODY_METHODS = + List.of("POST", "PUT", "PATCH"); + private final HttpClient httpClient; private final String baseUrl; private final String apiKey; @@ -89,6 +92,104 @@ public RateLimitSnapshot lastRateLimit() { return lastRateLimit.get(); } + /** + * Send a request and return the raw response bytes plus selected headers. + * Used by file-download endpoints (e.g., transactions export). Goes through + * the full retry / rate-limit / error pipeline like {@link #request(Options)}, + * but does not parse the body. + */ + public RawResponse requestRaw(Options opts) { + Map queryView = opts.query == null ? Map.of() : Map.copyOf(opts.query); + RequestSummary summary = new RequestSummary( + opts.method, opts.path, queryView, + opts.bodyShape, redactBearer(apiKey)); + + String idempotencyKey = opts.idempotencyKey; + if (idempotencyKey == null && MUTATING_METHODS.contains(opts.method)) { + idempotencyKey = UUID.randomUUID().toString(); + } + + IOException lastIo = null; + ApiException lastApi = null; + for (int attempt = 1; attempt <= retryPolicy.maxAttempts(); attempt++) { + HttpRequest httpRequest = build(opts, idempotencyKey); + HttpResponse response; + try { + response = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofByteArray()); + } catch (java.net.http.HttpTimeoutException e) { + lastIo = e; + if (logger != null) logger.accept(new LogEvent(summary, attempt, -1, e)); + if (!retryPolicy.retryOnNetwork() || attempt == retryPolicy.maxAttempts()) { + throw new TimeoutException("request timed out", summary, attempt, e); + } + sleep(backoff(attempt, null)); + continue; + } catch (SSLException e) { + throw new TlsException("TLS error: " + e.getMessage(), summary, attempt, e); + } catch (ConnectException | UnknownHostException e) { + lastIo = e; + if (logger != null) logger.accept(new LogEvent(summary, attempt, -1, e)); + if (!retryPolicy.retryOnNetwork() || attempt == retryPolicy.maxAttempts()) { + throw new NetworkException(e.getMessage(), summary, attempt, e); + } + sleep(backoff(attempt, null)); + continue; + } catch (IOException e) { + lastIo = e; + if (logger != null) logger.accept(new LogEvent(summary, attempt, -1, e)); + if (!retryPolicy.retryOnNetwork() || attempt == retryPolicy.maxAttempts()) { + throw new NetworkException(e.getMessage(), summary, attempt, e); + } + sleep(backoff(attempt, null)); + continue; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new TransportException("interrupted", "INTERRUPTED", + 0, null, null, null, null, summary, attempt, e) {}; + } + + captureRateLimit(response); + int status = response.statusCode(); + String requestId = header(response, "X-Request-Id"); + + if (logger != null) logger.accept(new LogEvent(summary, attempt, status, null)); + + if (status >= 200 && status < 300) { + byte[] body = response.body() == null ? new byte[0] : response.body(); + return new RawResponse( + status, body, + header(response, "Content-Type"), + header(response, "Content-Disposition"), + requestId); + } + + ApiException api = buildApiException(opts, summary, response, requestId, attempt); + lastApi = api; + + if (shouldRetry(status, attempt)) { + Duration sleepFor = backoff(attempt, retryAfterSeconds(response)); + sleep(sleepFor); + continue; + } + throw api; + } + + if (lastApi != null) throw lastApi; + if (lastIo != null) throw new NetworkException("retries exhausted", summary, + retryPolicy.maxAttempts(), lastIo); + throw new NetworkException("unexpected transport state", summary, + retryPolicy.maxAttempts(), null); + } + + /** Raw HTTP response body + selected headers; returned by {@link #requestRaw(Options)}. */ + public record RawResponse( + int status, + byte[] body, + String contentType, + String contentDisposition, + String requestId + ) {} + /** * Send a request, applying retries / rate-limit awareness / caching. * @@ -216,6 +317,10 @@ private HttpRequest build(Options opts, String idempotencyKey) { HttpRequest.BodyPublisher body = HttpRequest.BodyPublishers.noBody(); if (opts.body != null) { body = HttpRequest.BodyPublishers.ofByteArray(opts.body); + } + // why: spec mandates Content-Type: application/json on every + // POST/PUT/PATCH (415 otherwise), even if the body is empty. + if (JSON_BODY_METHODS.contains(opts.method)) { rb.header("Content-Type", "application/json"); } @@ -390,6 +495,24 @@ public Options query(String k, String v) { public Options cacheTtl(Duration d) { this.cacheTtl = d; return this; } public Options idempotencyKey(String k) { this.idempotencyKey = k; return this; } + + /** + * Serialize the given POJO as JSON and attach as the request body. + * Returns this for chaining. + */ + public Options jsonBody(Object value) { + byte[] bytes = com.tesote.sdk.internal.Json.toBytes(value); + this.body = bytes; + this.bodyShape = bytes.length + " bytes"; + return this; + } + + public Options query(java.util.Map entries) { + if (entries == null || entries.isEmpty()) return this; + if (query == null) query = new HashMap<>(); + entries.forEach((k, v) -> { if (v != null) query.put(k, v); }); + return this; + } } /** Configurable retry parameters. */ diff --git a/packages/java/src/main/java/com/tesote/sdk/errors/AccountNotFoundException.java b/packages/java/src/main/java/com/tesote/sdk/errors/AccountNotFoundException.java new file mode 100644 index 0000000..929f17a --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/errors/AccountNotFoundException.java @@ -0,0 +1,11 @@ +package com.tesote.sdk.errors; + +public final class AccountNotFoundException extends NotFoundException { + public AccountNotFoundException(String message, String errorCode, int httpStatus, + String requestId, String errorId, Integer retryAfter, + String responseBody, RequestSummary requestSummary, + int attempts, Throwable cause) { + super(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause); + } +} diff --git a/packages/java/src/main/java/com/tesote/sdk/errors/BankConnectionNotFoundException.java b/packages/java/src/main/java/com/tesote/sdk/errors/BankConnectionNotFoundException.java new file mode 100644 index 0000000..fa5238f --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/errors/BankConnectionNotFoundException.java @@ -0,0 +1,11 @@ +package com.tesote.sdk.errors; + +public final class BankConnectionNotFoundException extends NotFoundException { + public BankConnectionNotFoundException(String message, String errorCode, int httpStatus, + String requestId, String errorId, Integer retryAfter, + String responseBody, RequestSummary requestSummary, + int attempts, Throwable cause) { + super(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause); + } +} diff --git a/packages/java/src/main/java/com/tesote/sdk/errors/BankSubmissionException.java b/packages/java/src/main/java/com/tesote/sdk/errors/BankSubmissionException.java new file mode 100644 index 0000000..8c1f386 --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/errors/BankSubmissionException.java @@ -0,0 +1,11 @@ +package com.tesote.sdk.errors; + +public final class BankSubmissionException extends UnprocessableContentException { + public BankSubmissionException(String message, String errorCode, int httpStatus, + String requestId, String errorId, Integer retryAfter, + String responseBody, RequestSummary requestSummary, + int attempts, Throwable cause) { + super(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause); + } +} diff --git a/packages/java/src/main/java/com/tesote/sdk/errors/BankUnderMaintenanceException.java b/packages/java/src/main/java/com/tesote/sdk/errors/BankUnderMaintenanceException.java new file mode 100644 index 0000000..0811738 --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/errors/BankUnderMaintenanceException.java @@ -0,0 +1,16 @@ +package com.tesote.sdk.errors; + +/** + * 503 — the upstream bank reported maintenance. Retry after the configured + * window. Distinct from the broader {@link ServiceUnavailableException} + * (which signals API-side pause mode). + */ +public final class BankUnderMaintenanceException extends ApiException { + public BankUnderMaintenanceException(String message, String errorCode, int httpStatus, + String requestId, String errorId, Integer retryAfter, + String responseBody, RequestSummary requestSummary, + int attempts, Throwable cause) { + super(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause); + } +} diff --git a/packages/java/src/main/java/com/tesote/sdk/errors/BatchNotFoundException.java b/packages/java/src/main/java/com/tesote/sdk/errors/BatchNotFoundException.java new file mode 100644 index 0000000..e3131bd --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/errors/BatchNotFoundException.java @@ -0,0 +1,11 @@ +package com.tesote.sdk.errors; + +public final class BatchNotFoundException extends NotFoundException { + public BatchNotFoundException(String message, String errorCode, int httpStatus, + String requestId, String errorId, Integer retryAfter, + String responseBody, RequestSummary requestSummary, + int attempts, Throwable cause) { + super(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause); + } +} diff --git a/packages/java/src/main/java/com/tesote/sdk/errors/BatchValidationException.java b/packages/java/src/main/java/com/tesote/sdk/errors/BatchValidationException.java new file mode 100644 index 0000000..900ec51 --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/errors/BatchValidationException.java @@ -0,0 +1,11 @@ +package com.tesote.sdk.errors; + +public final class BatchValidationException extends ValidationException { + public BatchValidationException(String message, String errorCode, int httpStatus, + String requestId, String errorId, Integer retryAfter, + String responseBody, RequestSummary requestSummary, + int attempts, Throwable cause) { + super(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause); + } +} diff --git a/packages/java/src/main/java/com/tesote/sdk/errors/CategoryNotFoundException.java b/packages/java/src/main/java/com/tesote/sdk/errors/CategoryNotFoundException.java new file mode 100644 index 0000000..9a58896 --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/errors/CategoryNotFoundException.java @@ -0,0 +1,11 @@ +package com.tesote.sdk.errors; + +public final class CategoryNotFoundException extends NotFoundException { + public CategoryNotFoundException(String message, String errorCode, int httpStatus, + String requestId, String errorId, Integer retryAfter, + String responseBody, RequestSummary requestSummary, + int attempts, Throwable cause) { + super(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause); + } +} diff --git a/packages/java/src/main/java/com/tesote/sdk/errors/CounterpartyNotFoundException.java b/packages/java/src/main/java/com/tesote/sdk/errors/CounterpartyNotFoundException.java new file mode 100644 index 0000000..f488ed0 --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/errors/CounterpartyNotFoundException.java @@ -0,0 +1,11 @@ +package com.tesote.sdk.errors; + +public final class CounterpartyNotFoundException extends NotFoundException { + public CounterpartyNotFoundException(String message, String errorCode, int httpStatus, + String requestId, String errorId, Integer retryAfter, + String responseBody, RequestSummary requestSummary, + int attempts, Throwable cause) { + super(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause); + } +} diff --git a/packages/java/src/main/java/com/tesote/sdk/errors/ErrorDispatcher.java b/packages/java/src/main/java/com/tesote/sdk/errors/ErrorDispatcher.java index dd884e3..d8dd439 100644 --- a/packages/java/src/main/java/com/tesote/sdk/errors/ErrorDispatcher.java +++ b/packages/java/src/main/java/com/tesote/sdk/errors/ErrorDispatcher.java @@ -44,12 +44,84 @@ public static ApiException dispatch( case "INVALID_DATE_RANGE" -> new InvalidDateRangeException( message, code, httpStatus, requestId, errorId, retryAfter, responseBody, requestSummary, attempts, cause); + case "MISSING_DATE_RANGE" -> new MissingDateRangeException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause); + case "INVALID_CURSOR" -> new InvalidCursorException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause); + case "INVALID_COUNT" -> new InvalidCountException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause); + case "INVALID_LIMIT" -> new InvalidLimitException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause); + case "INVALID_QUERY" -> new InvalidQueryException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause); case "UNPROCESSABLE_CONTENT" -> new UnprocessableContentException( message, code, httpStatus, requestId, errorId, retryAfter, responseBody, requestSummary, attempts, cause); case "RATE_LIMIT_EXCEEDED" -> new RateLimitExceededException( message, code, httpStatus, requestId, errorId, retryAfter, responseBody, requestSummary, attempts, cause); + case "SYNC_RATE_LIMIT_EXCEEDED" -> new SyncRateLimitExceededException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause); + case "SYNC_IN_PROGRESS" -> new SyncInProgressException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause); + case "INVALID_ORDER_STATE" -> new InvalidOrderStateException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause); + case "VALIDATION_ERROR" -> new ValidationException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause); + case "BATCH_VALIDATION_ERROR" -> new BatchValidationException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause); + case "BANK_SUBMISSION_ERROR" -> new BankSubmissionException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause); + case "BANK_UNDER_MAINTENANCE" -> new BankUnderMaintenanceException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause); + case "INTERNAL_ERROR" -> new InternalErrorException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause); + case "ACCOUNT_NOT_FOUND" -> new AccountNotFoundException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause); + case "TRANSACTION_NOT_FOUND" -> new TransactionNotFoundException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause); + case "SYNC_SESSION_NOT_FOUND" -> new SyncSessionNotFoundException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause); + case "PAYMENT_METHOD_NOT_FOUND" -> new PaymentMethodNotFoundException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause); + case "TRANSACTION_ORDER_NOT_FOUND" -> new TransactionOrderNotFoundException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause); + case "BATCH_NOT_FOUND" -> new BatchNotFoundException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause); + case "BANK_CONNECTION_NOT_FOUND" -> new BankConnectionNotFoundException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause); + case "CATEGORY_NOT_FOUND" -> new CategoryNotFoundException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause); + case "COUNTERPARTY_NOT_FOUND" -> new CounterpartyNotFoundException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause); + case "LEGAL_ENTITY_NOT_FOUND" -> new LegalEntityNotFoundException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause); + case "WEBHOOK_NOT_FOUND" -> new WebhookNotFoundException( + message, code, httpStatus, requestId, errorId, retryAfter, + responseBody, requestSummary, attempts, cause); default -> { // why: 503 with no envelope code is the documented "pause mode" // signal — surface it as ServiceUnavailableException so callers diff --git a/packages/java/src/main/java/com/tesote/sdk/errors/InternalErrorException.java b/packages/java/src/main/java/com/tesote/sdk/errors/InternalErrorException.java new file mode 100644 index 0000000..e041dd4 --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/errors/InternalErrorException.java @@ -0,0 +1,14 @@ +package com.tesote.sdk.errors; + +/** + * 500 — server-side error. Includes {@link #errorId()} for support tickets. + */ +public final class InternalErrorException extends ApiException { + public InternalErrorException(String message, String errorCode, int httpStatus, + String requestId, String errorId, Integer retryAfter, + String responseBody, RequestSummary requestSummary, + int attempts, Throwable cause) { + super(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause); + } +} diff --git a/packages/java/src/main/java/com/tesote/sdk/errors/InvalidCountException.java b/packages/java/src/main/java/com/tesote/sdk/errors/InvalidCountException.java new file mode 100644 index 0000000..4c81f05 --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/errors/InvalidCountException.java @@ -0,0 +1,11 @@ +package com.tesote.sdk.errors; + +public final class InvalidCountException extends UnprocessableContentException { + public InvalidCountException(String message, String errorCode, int httpStatus, + String requestId, String errorId, Integer retryAfter, + String responseBody, RequestSummary requestSummary, + int attempts, Throwable cause) { + super(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause); + } +} diff --git a/packages/java/src/main/java/com/tesote/sdk/errors/InvalidCursorException.java b/packages/java/src/main/java/com/tesote/sdk/errors/InvalidCursorException.java new file mode 100644 index 0000000..c4349a9 --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/errors/InvalidCursorException.java @@ -0,0 +1,11 @@ +package com.tesote.sdk.errors; + +public final class InvalidCursorException extends UnprocessableContentException { + public InvalidCursorException(String message, String errorCode, int httpStatus, + String requestId, String errorId, Integer retryAfter, + String responseBody, RequestSummary requestSummary, + int attempts, Throwable cause) { + super(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause); + } +} diff --git a/packages/java/src/main/java/com/tesote/sdk/errors/InvalidLimitException.java b/packages/java/src/main/java/com/tesote/sdk/errors/InvalidLimitException.java new file mode 100644 index 0000000..67cc27c --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/errors/InvalidLimitException.java @@ -0,0 +1,11 @@ +package com.tesote.sdk.errors; + +public final class InvalidLimitException extends UnprocessableContentException { + public InvalidLimitException(String message, String errorCode, int httpStatus, + String requestId, String errorId, Integer retryAfter, + String responseBody, RequestSummary requestSummary, + int attempts, Throwable cause) { + super(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause); + } +} diff --git a/packages/java/src/main/java/com/tesote/sdk/errors/InvalidOrderStateException.java b/packages/java/src/main/java/com/tesote/sdk/errors/InvalidOrderStateException.java new file mode 100644 index 0000000..31d29d3 --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/errors/InvalidOrderStateException.java @@ -0,0 +1,15 @@ +package com.tesote.sdk.errors; + +/** + * 409 — order is not in a state that permits the attempted transition + * (e.g., submitting a non-draft order, cancelling a completed order). + */ +public final class InvalidOrderStateException extends ApiException { + public InvalidOrderStateException(String message, String errorCode, int httpStatus, + String requestId, String errorId, Integer retryAfter, + String responseBody, RequestSummary requestSummary, + int attempts, Throwable cause) { + super(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause); + } +} diff --git a/packages/java/src/main/java/com/tesote/sdk/errors/InvalidQueryException.java b/packages/java/src/main/java/com/tesote/sdk/errors/InvalidQueryException.java new file mode 100644 index 0000000..6a5d70c --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/errors/InvalidQueryException.java @@ -0,0 +1,11 @@ +package com.tesote.sdk.errors; + +public final class InvalidQueryException extends UnprocessableContentException { + public InvalidQueryException(String message, String errorCode, int httpStatus, + String requestId, String errorId, Integer retryAfter, + String responseBody, RequestSummary requestSummary, + int attempts, Throwable cause) { + super(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause); + } +} diff --git a/packages/java/src/main/java/com/tesote/sdk/errors/LegalEntityNotFoundException.java b/packages/java/src/main/java/com/tesote/sdk/errors/LegalEntityNotFoundException.java new file mode 100644 index 0000000..3006291 --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/errors/LegalEntityNotFoundException.java @@ -0,0 +1,11 @@ +package com.tesote.sdk.errors; + +public final class LegalEntityNotFoundException extends NotFoundException { + public LegalEntityNotFoundException(String message, String errorCode, int httpStatus, + String requestId, String errorId, Integer retryAfter, + String responseBody, RequestSummary requestSummary, + int attempts, Throwable cause) { + super(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause); + } +} diff --git a/packages/java/src/main/java/com/tesote/sdk/errors/MissingDateRangeException.java b/packages/java/src/main/java/com/tesote/sdk/errors/MissingDateRangeException.java new file mode 100644 index 0000000..339bee9 --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/errors/MissingDateRangeException.java @@ -0,0 +1,11 @@ +package com.tesote.sdk.errors; + +public final class MissingDateRangeException extends UnprocessableContentException { + public MissingDateRangeException(String message, String errorCode, int httpStatus, + String requestId, String errorId, Integer retryAfter, + String responseBody, RequestSummary requestSummary, + int attempts, Throwable cause) { + super(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause); + } +} diff --git a/packages/java/src/main/java/com/tesote/sdk/errors/NotFoundException.java b/packages/java/src/main/java/com/tesote/sdk/errors/NotFoundException.java new file mode 100644 index 0000000..bf70112 --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/errors/NotFoundException.java @@ -0,0 +1,16 @@ +package com.tesote.sdk.errors; + +/** + * Resource-not-found family. Concrete subclasses correspond to specific + * {@code *_NOT_FOUND} error codes; callers may catch this base when the + * specific resource doesn't matter. + */ +public class NotFoundException extends ApiException { + public NotFoundException(String message, String errorCode, int httpStatus, + String requestId, String errorId, Integer retryAfter, + String responseBody, RequestSummary requestSummary, + int attempts, Throwable cause) { + super(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause); + } +} diff --git a/packages/java/src/main/java/com/tesote/sdk/errors/PaymentMethodNotFoundException.java b/packages/java/src/main/java/com/tesote/sdk/errors/PaymentMethodNotFoundException.java new file mode 100644 index 0000000..97f4324 --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/errors/PaymentMethodNotFoundException.java @@ -0,0 +1,11 @@ +package com.tesote.sdk.errors; + +public final class PaymentMethodNotFoundException extends NotFoundException { + public PaymentMethodNotFoundException(String message, String errorCode, int httpStatus, + String requestId, String errorId, Integer retryAfter, + String responseBody, RequestSummary requestSummary, + int attempts, Throwable cause) { + super(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause); + } +} diff --git a/packages/java/src/main/java/com/tesote/sdk/errors/SyncInProgressException.java b/packages/java/src/main/java/com/tesote/sdk/errors/SyncInProgressException.java new file mode 100644 index 0000000..599f6ee --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/errors/SyncInProgressException.java @@ -0,0 +1,16 @@ +package com.tesote.sdk.errors; + +/** + * 409 — a sync session for the bank connection is already running. + * The active session id is in {@link #responseBody()} as + * {@code current_session_id}. + */ +public final class SyncInProgressException extends ApiException { + public SyncInProgressException(String message, String errorCode, int httpStatus, + String requestId, String errorId, Integer retryAfter, + String responseBody, RequestSummary requestSummary, + int attempts, Throwable cause) { + super(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause); + } +} diff --git a/packages/java/src/main/java/com/tesote/sdk/errors/SyncRateLimitExceededException.java b/packages/java/src/main/java/com/tesote/sdk/errors/SyncRateLimitExceededException.java new file mode 100644 index 0000000..8498cdf --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/errors/SyncRateLimitExceededException.java @@ -0,0 +1,15 @@ +package com.tesote.sdk.errors; + +/** + * 429 — per-bank-connection sync was triggered too soon after the previous + * one. Distinct from the per-API-key {@link RateLimitExceededException}. + */ +public final class SyncRateLimitExceededException extends ApiException { + public SyncRateLimitExceededException(String message, String errorCode, int httpStatus, + String requestId, String errorId, Integer retryAfter, + String responseBody, RequestSummary requestSummary, + int attempts, Throwable cause) { + super(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause); + } +} diff --git a/packages/java/src/main/java/com/tesote/sdk/errors/SyncSessionNotFoundException.java b/packages/java/src/main/java/com/tesote/sdk/errors/SyncSessionNotFoundException.java new file mode 100644 index 0000000..e1d1b2b --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/errors/SyncSessionNotFoundException.java @@ -0,0 +1,11 @@ +package com.tesote.sdk.errors; + +public final class SyncSessionNotFoundException extends NotFoundException { + public SyncSessionNotFoundException(String message, String errorCode, int httpStatus, + String requestId, String errorId, Integer retryAfter, + String responseBody, RequestSummary requestSummary, + int attempts, Throwable cause) { + super(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause); + } +} diff --git a/packages/java/src/main/java/com/tesote/sdk/errors/TransactionNotFoundException.java b/packages/java/src/main/java/com/tesote/sdk/errors/TransactionNotFoundException.java new file mode 100644 index 0000000..5c8396d --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/errors/TransactionNotFoundException.java @@ -0,0 +1,11 @@ +package com.tesote.sdk.errors; + +public final class TransactionNotFoundException extends NotFoundException { + public TransactionNotFoundException(String message, String errorCode, int httpStatus, + String requestId, String errorId, Integer retryAfter, + String responseBody, RequestSummary requestSummary, + int attempts, Throwable cause) { + super(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause); + } +} diff --git a/packages/java/src/main/java/com/tesote/sdk/errors/TransactionOrderNotFoundException.java b/packages/java/src/main/java/com/tesote/sdk/errors/TransactionOrderNotFoundException.java new file mode 100644 index 0000000..e8d030d --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/errors/TransactionOrderNotFoundException.java @@ -0,0 +1,11 @@ +package com.tesote.sdk.errors; + +public final class TransactionOrderNotFoundException extends NotFoundException { + public TransactionOrderNotFoundException(String message, String errorCode, int httpStatus, + String requestId, String errorId, Integer retryAfter, + String responseBody, RequestSummary requestSummary, + int attempts, Throwable cause) { + super(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause); + } +} diff --git a/packages/java/src/main/java/com/tesote/sdk/errors/ValidationException.java b/packages/java/src/main/java/com/tesote/sdk/errors/ValidationException.java new file mode 100644 index 0000000..97d93f1 --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/errors/ValidationException.java @@ -0,0 +1,15 @@ +package com.tesote.sdk.errors; + +/** + * Generic 400 {@code VALIDATION_ERROR} from the API. Specialized subclasses + * exist for batch / bank-submission validation. + */ +public class ValidationException extends ApiException { + public ValidationException(String message, String errorCode, int httpStatus, + String requestId, String errorId, Integer retryAfter, + String responseBody, RequestSummary requestSummary, + int attempts, Throwable cause) { + super(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause); + } +} diff --git a/packages/java/src/main/java/com/tesote/sdk/errors/WebhookNotFoundException.java b/packages/java/src/main/java/com/tesote/sdk/errors/WebhookNotFoundException.java new file mode 100644 index 0000000..119f07f --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/errors/WebhookNotFoundException.java @@ -0,0 +1,11 @@ +package com.tesote.sdk.errors; + +public final class WebhookNotFoundException extends NotFoundException { + public WebhookNotFoundException(String message, String errorCode, int httpStatus, + String requestId, String errorId, Integer retryAfter, + String responseBody, RequestSummary requestSummary, + int attempts, Throwable cause) { + super(message, errorCode, httpStatus, requestId, errorId, + retryAfter, responseBody, requestSummary, attempts, cause); + } +} diff --git a/packages/java/src/main/java/com/tesote/sdk/internal/Json.java b/packages/java/src/main/java/com/tesote/sdk/internal/Json.java index 373c02f..8ed2b89 100644 --- a/packages/java/src/main/java/com/tesote/sdk/internal/Json.java +++ b/packages/java/src/main/java/com/tesote/sdk/internal/Json.java @@ -3,10 +3,15 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.core.type.TypeReference; /** * Thin wrapper around a single shared {@link ObjectMapper}. ObjectMapper is * thread-safe once configured; sharing avoids re-allocating per request. + * + *

No JSR-310 module: the SDK keeps jackson-databind as its only runtime + * dep, so date/time API fields are exposed as {@code String} on the model + * records and callers parse with {@link java.time.OffsetDateTime#parse}. */ public final class Json { public static final ObjectMapper MAPPER = new ObjectMapper(); @@ -31,4 +36,31 @@ public static String stringify(Object value) { throw new IllegalStateException("failed to serialize " + value.getClass(), e); } } + + public static T treeToValue(JsonNode node, Class type) { + if (node == null || node.isMissingNode() || node.isNull()) return null; + try { + return MAPPER.treeToValue(node, type); + } catch (JsonProcessingException e) { + throw new IllegalStateException( + "failed to deserialize " + type.getSimpleName() + ": " + e.getMessage(), e); + } + } + + public static T treeToValue(JsonNode node, TypeReference type) { + if (node == null || node.isMissingNode() || node.isNull()) return null; + try { + return MAPPER.readValue(MAPPER.treeAsTokens(node), type); + } catch (java.io.IOException e) { + throw new IllegalStateException("failed to deserialize: " + e.getMessage(), e); + } + } + + public static byte[] toBytes(Object value) { + try { + return MAPPER.writeValueAsBytes(value); + } catch (JsonProcessingException e) { + throw new IllegalStateException("failed to serialize " + value.getClass(), e); + } + } } diff --git a/packages/java/src/main/java/com/tesote/sdk/internal/QueryParams.java b/packages/java/src/main/java/com/tesote/sdk/internal/QueryParams.java new file mode 100644 index 0000000..4938f50 --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/internal/QueryParams.java @@ -0,0 +1,33 @@ +package com.tesote.sdk.internal; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Tiny builder for query-string maps. Skips null values so callers don't + * have to gate every {@code .put} on a not-null check. + */ +public final class QueryParams { + private final Map values = new LinkedHashMap<>(); + + public static QueryParams of() { return new QueryParams(); } + + public QueryParams put(String key, String value) { + if (value != null) values.put(key, value); + return this; + } + + public QueryParams put(String key, Number value) { + if (value != null) values.put(key, value.toString()); + return this; + } + + public QueryParams put(String key, Boolean value) { + if (value != null) values.put(key, value.toString()); + return this; + } + + public Map build() { return values; } + + public boolean isEmpty() { return values.isEmpty(); } +} diff --git a/packages/java/src/main/java/com/tesote/sdk/models/Account.java b/packages/java/src/main/java/com/tesote/sdk/models/Account.java new file mode 100644 index 0000000..04c4692 --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/models/Account.java @@ -0,0 +1,41 @@ +package com.tesote.sdk.models; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Account record (v1 and v2 share the same shape). + * + *

Balance fields inside {@link AccountData} only appear when the + * workspace has {@code display_balances_in_api} enabled. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record Account( + @JsonProperty("id") String id, + @JsonProperty("name") String name, + @JsonProperty("data") AccountData data, + @JsonProperty("bank") Bank bank, + @JsonProperty("legal_entity") LegalEntity legalEntity, + @JsonProperty("tesote_created_at") String tesoteCreatedAt, + @JsonProperty("tesote_updated_at") String tesoteUpdatedAt +) { + @JsonIgnoreProperties(ignoreUnknown = true) + public record AccountData( + @JsonProperty("masked_account_number") String maskedAccountNumber, + @JsonProperty("currency") String currency, + @JsonProperty("transactions_data_current_as_of") String transactionsDataCurrentAsOf, + @JsonProperty("balance_data_current_as_of") String balanceDataCurrentAsOf, + @JsonProperty("custom_user_provided_identifier") String customUserProvidedIdentifier, + @JsonProperty("balance_cents") String balanceCents, + @JsonProperty("available_balance_cents") String availableBalanceCents + ) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Bank(@JsonProperty("name") String name) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record LegalEntity( + @JsonProperty("id") String id, + @JsonProperty("legal_name") String legalName + ) {} +} diff --git a/packages/java/src/main/java/com/tesote/sdk/models/AccountSyncResponse.java b/packages/java/src/main/java/com/tesote/sdk/models/AccountSyncResponse.java new file mode 100644 index 0000000..d4ecc2c --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/models/AccountSyncResponse.java @@ -0,0 +1,15 @@ +package com.tesote.sdk.models; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Response body for {@code POST /v2/accounts/{id}/sync}. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record AccountSyncResponse( + @JsonProperty("message") String message, + @JsonProperty("sync_session_id") String syncSessionId, + @JsonProperty("status") String status, + @JsonProperty("started_at") String startedAt +) {} diff --git a/packages/java/src/main/java/com/tesote/sdk/models/AccountsPage.java b/packages/java/src/main/java/com/tesote/sdk/models/AccountsPage.java new file mode 100644 index 0000000..8e1d2c8 --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/models/AccountsPage.java @@ -0,0 +1,13 @@ +package com.tesote.sdk.models; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record AccountsPage( + @JsonProperty("total") Integer total, + @JsonProperty("accounts") List accounts, + @JsonProperty("pagination") PagePagination pagination +) {} diff --git a/packages/java/src/main/java/com/tesote/sdk/models/BatchActionResponse.java b/packages/java/src/main/java/com/tesote/sdk/models/BatchActionResponse.java new file mode 100644 index 0000000..68f740d --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/models/BatchActionResponse.java @@ -0,0 +1,22 @@ +package com.tesote.sdk.models; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; +import java.util.Map; + +/** + * Response shape for batch approve / submit / cancel. + * Different actions populate different counts; cancel additionally fills + * {@code skipped} and may include per-order {@code errors}. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record BatchActionResponse( + @JsonProperty("approved") Integer approved, + @JsonProperty("enqueued") Integer enqueued, + @JsonProperty("cancelled") Integer cancelled, + @JsonProperty("failed") Integer failed, + @JsonProperty("skipped") Integer skipped, + @JsonProperty("errors") List> errors +) {} diff --git a/packages/java/src/main/java/com/tesote/sdk/models/BatchCreateResponse.java b/packages/java/src/main/java/com/tesote/sdk/models/BatchCreateResponse.java new file mode 100644 index 0000000..27d166e --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/models/BatchCreateResponse.java @@ -0,0 +1,14 @@ +package com.tesote.sdk.models; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; +import java.util.Map; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record BatchCreateResponse( + @JsonProperty("batch_id") String batchId, + @JsonProperty("orders") List orders, + @JsonProperty("errors") List> errors +) {} diff --git a/packages/java/src/main/java/com/tesote/sdk/models/BatchSummary.java b/packages/java/src/main/java/com/tesote/sdk/models/BatchSummary.java new file mode 100644 index 0000000..05e9707 --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/models/BatchSummary.java @@ -0,0 +1,19 @@ +package com.tesote.sdk.models; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; +import java.util.Map; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record BatchSummary( + @JsonProperty("batch_id") String batchId, + @JsonProperty("total_orders") Integer totalOrders, + @JsonProperty("total_amount_cents") Long totalAmountCents, + @JsonProperty("amount_currency") String amountCurrency, + @JsonProperty("statuses") Map statuses, + @JsonProperty("batch_status") String batchStatus, + @JsonProperty("created_at") String createdAt, + @JsonProperty("orders") List orders +) {} diff --git a/packages/java/src/main/java/com/tesote/sdk/models/BulkResponse.java b/packages/java/src/main/java/com/tesote/sdk/models/BulkResponse.java new file mode 100644 index 0000000..300c976 --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/models/BulkResponse.java @@ -0,0 +1,16 @@ +package com.tesote.sdk.models; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record BulkResponse(@JsonProperty("bulk_results") List bulkResults) { + @JsonIgnoreProperties(ignoreUnknown = true) + public record BulkResult( + @JsonProperty("account_id") String accountId, + @JsonProperty("transactions") List transactions, + @JsonProperty("pagination") CursorPagination pagination + ) {} +} diff --git a/packages/java/src/main/java/com/tesote/sdk/models/CursorPagination.java b/packages/java/src/main/java/com/tesote/sdk/models/CursorPagination.java new file mode 100644 index 0000000..3425f8b --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/models/CursorPagination.java @@ -0,0 +1,19 @@ +package com.tesote.sdk.models; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Pagination metadata for cursor-based endpoints (transactions list). + * + *

{@code afterId} is the last item in the page; {@code beforeId} is the + * first. Pass {@code afterId} as {@code transactions_after_id} on the next + * call to fetch the following page. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record CursorPagination( + @JsonProperty("has_more") Boolean hasMore, + @JsonProperty("per_page") Integer perPage, + @JsonProperty("after_id") String afterId, + @JsonProperty("before_id") String beforeId +) {} diff --git a/packages/java/src/main/java/com/tesote/sdk/models/OffsetPage.java b/packages/java/src/main/java/com/tesote/sdk/models/OffsetPage.java new file mode 100644 index 0000000..c01f122 --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/models/OffsetPage.java @@ -0,0 +1,18 @@ +package com.tesote.sdk.models; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** + * Generic offset-paginated wrapper used by transaction-orders and + * payment-methods list endpoints. {@code items} is the page payload. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record OffsetPage( + @JsonProperty("items") List items, + @JsonProperty("limit") Integer limit, + @JsonProperty("offset") Integer offset, + @JsonProperty("has_more") Boolean hasMore +) {} diff --git a/packages/java/src/main/java/com/tesote/sdk/models/PagePagination.java b/packages/java/src/main/java/com/tesote/sdk/models/PagePagination.java new file mode 100644 index 0000000..75dcd31 --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/models/PagePagination.java @@ -0,0 +1,15 @@ +package com.tesote.sdk.models; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Pagination metadata for page-based endpoints (accounts list). + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record PagePagination( + @JsonProperty("current_page") Integer currentPage, + @JsonProperty("per_page") Integer perPage, + @JsonProperty("total_pages") Integer totalPages, + @JsonProperty("total_count") Integer totalCount +) {} diff --git a/packages/java/src/main/java/com/tesote/sdk/models/PaymentMethod.java b/packages/java/src/main/java/com/tesote/sdk/models/PaymentMethod.java new file mode 100644 index 0000000..89d3f95 --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/models/PaymentMethod.java @@ -0,0 +1,34 @@ +package com.tesote.sdk.models; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Map; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record PaymentMethod( + @JsonProperty("id") String id, + @JsonProperty("method_type") String methodType, + @JsonProperty("currency") String currency, + @JsonProperty("label") String label, + @JsonProperty("details") Map details, + @JsonProperty("verified") Boolean verified, + @JsonProperty("verified_at") String verifiedAt, + @JsonProperty("last_used_at") String lastUsedAt, + @JsonProperty("counterparty") CounterpartyRef counterparty, + @JsonProperty("tesote_account") TesoteAccountRef tesoteAccount, + @JsonProperty("created_at") String createdAt, + @JsonProperty("updated_at") String updatedAt +) { + @JsonIgnoreProperties(ignoreUnknown = true) + public record CounterpartyRef( + @JsonProperty("id") String id, + @JsonProperty("name") String name + ) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record TesoteAccountRef( + @JsonProperty("id") String id, + @JsonProperty("name") String name + ) {} +} diff --git a/packages/java/src/main/java/com/tesote/sdk/models/Status.java b/packages/java/src/main/java/com/tesote/sdk/models/Status.java new file mode 100644 index 0000000..bc1e528 --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/models/Status.java @@ -0,0 +1,10 @@ +package com.tesote.sdk.models; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record Status( + @JsonProperty("status") String status, + @JsonProperty("authenticated") Boolean authenticated +) {} diff --git a/packages/java/src/main/java/com/tesote/sdk/models/SyncSession.java b/packages/java/src/main/java/com/tesote/sdk/models/SyncSession.java new file mode 100644 index 0000000..b018144 --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/models/SyncSession.java @@ -0,0 +1,29 @@ +package com.tesote.sdk.models; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record SyncSession( + @JsonProperty("id") String id, + @JsonProperty("status") String status, + @JsonProperty("started_at") String startedAt, + @JsonProperty("completed_at") String completedAt, + @JsonProperty("transactions_synced") Integer transactionsSynced, + @JsonProperty("accounts_count") Integer accountsCount, + @JsonProperty("error") SyncError error, + @JsonProperty("performance") Performance performance +) { + @JsonIgnoreProperties(ignoreUnknown = true) + public record SyncError( + @JsonProperty("type") String type, + @JsonProperty("message") String message + ) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Performance( + @JsonProperty("total_duration") Double totalDuration, + @JsonProperty("complexity_score") Double complexityScore, + @JsonProperty("sync_speed_score") Double syncSpeedScore + ) {} +} diff --git a/packages/java/src/main/java/com/tesote/sdk/models/SyncSessionsPage.java b/packages/java/src/main/java/com/tesote/sdk/models/SyncSessionsPage.java new file mode 100644 index 0000000..7bf85f5 --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/models/SyncSessionsPage.java @@ -0,0 +1,14 @@ +package com.tesote.sdk.models; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record SyncSessionsPage( + @JsonProperty("sync_sessions") List syncSessions, + @JsonProperty("limit") Integer limit, + @JsonProperty("offset") Integer offset, + @JsonProperty("has_more") Boolean hasMore +) {} diff --git a/packages/java/src/main/java/com/tesote/sdk/models/SyncTransaction.java b/packages/java/src/main/java/com/tesote/sdk/models/SyncTransaction.java new file mode 100644 index 0000000..cb96dc6 --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/models/SyncTransaction.java @@ -0,0 +1,26 @@ +package com.tesote.sdk.models; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.math.BigDecimal; +import java.util.List; + +/** + * Plaid-compatible flat transaction shape returned by sync endpoints. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record SyncTransaction( + @JsonProperty("transaction_id") String transactionId, + @JsonProperty("account_id") String accountId, + @JsonProperty("amount") BigDecimal amount, + @JsonProperty("iso_currency_code") String isoCurrencyCode, + @JsonProperty("unofficial_currency_code") String unofficialCurrencyCode, + @JsonProperty("date") String date, + @JsonProperty("datetime") String datetime, + @JsonProperty("name") String name, + @JsonProperty("merchant_name") String merchantName, + @JsonProperty("pending") Boolean pending, + @JsonProperty("category") List category, + @JsonProperty("running_balance_cents") Long runningBalanceCents +) {} diff --git a/packages/java/src/main/java/com/tesote/sdk/models/SyncTransactionsResponse.java b/packages/java/src/main/java/com/tesote/sdk/models/SyncTransactionsResponse.java new file mode 100644 index 0000000..687a84c --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/models/SyncTransactionsResponse.java @@ -0,0 +1,27 @@ +package com.tesote.sdk.models; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** + * Response body for {@code POST /v2/accounts/{id}/transactions/sync} and the + * legacy {@code POST /v2/transactions/sync}. + * + *

Persist {@link #nextCursor()} between calls to resume. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record SyncTransactionsResponse( + @JsonProperty("added") List added, + @JsonProperty("modified") List modified, + @JsonProperty("removed") List removed, + @JsonProperty("next_cursor") String nextCursor, + @JsonProperty("has_more") Boolean hasMore +) { + @JsonIgnoreProperties(ignoreUnknown = true) + public record RemovedTransaction( + @JsonProperty("transaction_id") String transactionId, + @JsonProperty("account_id") String accountId + ) {} +} diff --git a/packages/java/src/main/java/com/tesote/sdk/models/Transaction.java b/packages/java/src/main/java/com/tesote/sdk/models/Transaction.java new file mode 100644 index 0000000..d33313f --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/models/Transaction.java @@ -0,0 +1,45 @@ +package com.tesote.sdk.models; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** + * Transaction record. Used for /v1 reads and /v2/transactions/{id}. + * The flat {@link SyncTransaction} variant is only used in v2 sync responses. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record Transaction( + @JsonProperty("id") String id, + @JsonProperty("status") String status, + @JsonProperty("data") TransactionData data, + @JsonProperty("tesote_imported_at") String tesoteImportedAt, + @JsonProperty("tesote_updated_at") String tesoteUpdatedAt, + @JsonProperty("transaction_categories") List transactionCategories, + @JsonProperty("counterparty") Counterparty counterparty +) { + @JsonIgnoreProperties(ignoreUnknown = true) + public record TransactionData( + @JsonProperty("amount_cents") Long amountCents, + @JsonProperty("currency") String currency, + @JsonProperty("description") String description, + @JsonProperty("transaction_date") String transactionDate, + @JsonProperty("created_at") String createdAt, + @JsonProperty("created_at_date") String createdAtDate, + @JsonProperty("note") String note, + @JsonProperty("external_service_id") String externalServiceId, + @JsonProperty("running_balance_cents") Long runningBalanceCents + ) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Category( + @JsonProperty("name") String name, + @JsonProperty("external_category_code") String externalCategoryCode, + @JsonProperty("created_at") String createdAt, + @JsonProperty("updated_at") String updatedAt + ) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Counterparty(@JsonProperty("name") String name) {} +} diff --git a/packages/java/src/main/java/com/tesote/sdk/models/TransactionOrder.java b/packages/java/src/main/java/com/tesote/sdk/models/TransactionOrder.java new file mode 100644 index 0000000..6812eeb --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/models/TransactionOrder.java @@ -0,0 +1,77 @@ +package com.tesote.sdk.models; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.math.BigDecimal; +import java.util.Map; + +/** + * Transaction order — the unit of payment intent in v2 batches and + * transaction-order endpoints. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record TransactionOrder( + @JsonProperty("id") String id, + @JsonProperty("status") String status, + @JsonProperty("amount") BigDecimal amount, + @JsonProperty("currency") String currency, + @JsonProperty("description") String description, + @JsonProperty("reference") String reference, + @JsonProperty("external_reference") String externalReference, + @JsonProperty("idempotency_key") String idempotencyKey, + @JsonProperty("batch_id") String batchId, + @JsonProperty("scheduled_for") String scheduledFor, + @JsonProperty("approved_at") String approvedAt, + @JsonProperty("submitted_at") String submittedAt, + @JsonProperty("completed_at") String completedAt, + @JsonProperty("failed_at") String failedAt, + @JsonProperty("cancelled_at") String cancelledAt, + @JsonProperty("source_account") SourceAccount sourceAccount, + @JsonProperty("destination") Destination destination, + @JsonProperty("fee") Fee fee, + @JsonProperty("execution_strategy") String executionStrategy, + @JsonProperty("tesote_transaction") TesoteTransaction tesoteTransaction, + @JsonProperty("latest_attempt") LatestAttempt latestAttempt, + @JsonProperty("metadata") Map metadata, + @JsonProperty("created_at") String createdAt, + @JsonProperty("updated_at") String updatedAt +) { + @JsonIgnoreProperties(ignoreUnknown = true) + public record SourceAccount( + @JsonProperty("id") String id, + @JsonProperty("name") String name, + @JsonProperty("payment_method_id") String paymentMethodId + ) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Destination( + @JsonProperty("payment_method_id") String paymentMethodId, + @JsonProperty("counterparty_id") String counterpartyId, + @JsonProperty("counterparty_name") String counterpartyName + ) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Fee( + @JsonProperty("amount") BigDecimal amount, + @JsonProperty("currency") String currency + ) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record TesoteTransaction( + @JsonProperty("id") String id, + @JsonProperty("status") String status + ) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record LatestAttempt( + @JsonProperty("id") String id, + @JsonProperty("status") String status, + @JsonProperty("attempt_number") Integer attemptNumber, + @JsonProperty("external_reference") String externalReference, + @JsonProperty("submitted_at") String submittedAt, + @JsonProperty("completed_at") String completedAt, + @JsonProperty("error_code") String errorCode, + @JsonProperty("error_message") String errorMessage + ) {} +} diff --git a/packages/java/src/main/java/com/tesote/sdk/models/TransactionsExport.java b/packages/java/src/main/java/com/tesote/sdk/models/TransactionsExport.java new file mode 100644 index 0000000..f9d5419 --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/models/TransactionsExport.java @@ -0,0 +1,22 @@ +package com.tesote.sdk.models; + +/** + * File export from {@code GET /v2/accounts/{id}/transactions/export}. + * Holds raw bytes plus the server-suggested filename and content type. + */ +public record TransactionsExport( + byte[] body, + String filename, + String contentType +) { + public enum Format { + CSV("csv"), + JSON("json"); + + private final String wire; + + Format(String wire) { this.wire = wire; } + + public String wire() { return wire; } + } +} diff --git a/packages/java/src/main/java/com/tesote/sdk/models/TransactionsPage.java b/packages/java/src/main/java/com/tesote/sdk/models/TransactionsPage.java new file mode 100644 index 0000000..83f07c2 --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/models/TransactionsPage.java @@ -0,0 +1,13 @@ +package com.tesote.sdk.models; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record TransactionsPage( + @JsonProperty("total") Integer total, + @JsonProperty("transactions") List transactions, + @JsonProperty("pagination") CursorPagination pagination +) {} diff --git a/packages/java/src/main/java/com/tesote/sdk/models/TransactionsSearchResponse.java b/packages/java/src/main/java/com/tesote/sdk/models/TransactionsSearchResponse.java new file mode 100644 index 0000000..c797441 --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/models/TransactionsSearchResponse.java @@ -0,0 +1,12 @@ +package com.tesote.sdk.models; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record TransactionsSearchResponse( + @JsonProperty("transactions") List transactions, + @JsonProperty("total") Integer total +) {} diff --git a/packages/java/src/main/java/com/tesote/sdk/models/Whoami.java b/packages/java/src/main/java/com/tesote/sdk/models/Whoami.java new file mode 100644 index 0000000..e589926 --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/models/Whoami.java @@ -0,0 +1,14 @@ +package com.tesote.sdk.models; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record Whoami(@JsonProperty("client") Client client) { + @JsonIgnoreProperties(ignoreUnknown = true) + public record Client( + @JsonProperty("id") String id, + @JsonProperty("name") String name, + @JsonProperty("type") String type + ) {} +} diff --git a/packages/java/src/main/java/com/tesote/sdk/models/package-info.java b/packages/java/src/main/java/com/tesote/sdk/models/package-info.java new file mode 100644 index 0000000..b0beff7 --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/models/package-info.java @@ -0,0 +1,7 @@ +/** + * Typed model records for every API resource. Records are immutable, mirror the + * API JSON one-to-one (snake_case wire names mapped to camelCase Java fields + * via Jackson's {@code @JsonProperty}), and tolerate unknown fields so the + * API may add new keys without breaking older SDK builds. + */ +package com.tesote.sdk.models; diff --git a/packages/java/src/main/java/com/tesote/sdk/v1/AccountsClient.java b/packages/java/src/main/java/com/tesote/sdk/v1/AccountsClient.java new file mode 100644 index 0000000..659beb3 --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/v1/AccountsClient.java @@ -0,0 +1,63 @@ +package com.tesote.sdk.v1; + +import com.fasterxml.jackson.databind.JsonNode; +import com.tesote.sdk.Transport; +import com.tesote.sdk.internal.Json; +import com.tesote.sdk.internal.QueryParams; +import com.tesote.sdk.models.Account; +import com.tesote.sdk.models.AccountsPage; + +import java.time.Duration; +import java.util.Objects; + +/** + * Read-only accounts on {@code /v1/accounts}. + */ +public final class AccountsClient { + private static final Duration LIST_CACHE = Duration.ofMinutes(1); + private static final Duration GET_CACHE = Duration.ofMinutes(5); + + private final Transport transport; + + public AccountsClient(Transport transport) { this.transport = transport; } + + public AccountsPage list() { return list(new ListParams()); } + + public AccountsPage list(ListParams params) { + Objects.requireNonNull(params, "params"); + Transport.Options opts = Transport.Options.get("/v1/accounts") + .query(QueryParams.of() + .put("page", params.page) + .put("per_page", params.perPage) + .put("include", params.include) + .put("sort", params.sort) + .build()) + .cacheTtl(LIST_CACHE); + JsonNode node = transport.request(opts); + return Json.treeToValue(node, AccountsPage.class); + } + + public Account get(String id) { + Objects.requireNonNull(id, "id"); + Transport.Options opts = Transport.Options.get("/v1/accounts/" + encode(id)) + .cacheTtl(GET_CACHE); + return Json.treeToValue(transport.request(opts), Account.class); + } + + static String encode(String segment) { + return java.net.URLEncoder.encode(segment, java.nio.charset.StandardCharsets.UTF_8); + } + + /** Optional filter / sort / pagination params for {@link #list(ListParams)}. */ + public static final class ListParams { + public Integer page; + public Integer perPage; + public String include; + public String sort; + + public ListParams page(int v) { this.page = v; return this; } + public ListParams perPage(int v) { this.perPage = v; return this; } + public ListParams include(String v) { this.include = v; return this; } + public ListParams sort(String v) { this.sort = v; return this; } + } +} diff --git a/packages/java/src/main/java/com/tesote/sdk/v1/StatusClient.java b/packages/java/src/main/java/com/tesote/sdk/v1/StatusClient.java new file mode 100644 index 0000000..777bfba --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/v1/StatusClient.java @@ -0,0 +1,28 @@ +package com.tesote.sdk.v1; + +import com.fasterxml.jackson.databind.JsonNode; +import com.tesote.sdk.Transport; +import com.tesote.sdk.internal.Json; +import com.tesote.sdk.models.Status; +import com.tesote.sdk.models.Whoami; + +/** + * v1 status + whoami. {@code status()} works without an API key. + */ +public final class StatusClient { + private final Transport transport; + + public StatusClient(Transport transport) { this.transport = transport; } + + /** Public health check. Auth not required server-side. */ + public Status status() { + JsonNode node = transport.request(Transport.Options.get("/status")); + return Json.treeToValue(node, Status.class); + } + + /** Identity of the API key owner. Requires auth. */ + public Whoami whoami() { + JsonNode node = transport.request(Transport.Options.get("/whoami")); + return Json.treeToValue(node, Whoami.class); + } +} diff --git a/packages/java/src/main/java/com/tesote/sdk/v1/TransactionsClient.java b/packages/java/src/main/java/com/tesote/sdk/v1/TransactionsClient.java new file mode 100644 index 0000000..260ba0b --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/v1/TransactionsClient.java @@ -0,0 +1,75 @@ +package com.tesote.sdk.v1; + +import com.fasterxml.jackson.databind.JsonNode; +import com.tesote.sdk.Transport; +import com.tesote.sdk.internal.Json; +import com.tesote.sdk.internal.QueryParams; +import com.tesote.sdk.models.Transaction; +import com.tesote.sdk.models.TransactionsPage; + +import java.time.Duration; +import java.util.Objects; + +/** + * v1 transactions: list-for-account (cursor) and read-by-id. + */ +public final class TransactionsClient { + private static final Duration GET_CACHE = Duration.ofMinutes(5); + + private final Transport transport; + + public TransactionsClient(Transport transport) { this.transport = transport; } + + public TransactionsPage listForAccount(String accountId) { + return listForAccount(accountId, new ListParams()); + } + + public TransactionsPage listForAccount(String accountId, ListParams params) { + Objects.requireNonNull(accountId, "accountId"); + Objects.requireNonNull(params, "params"); + Transport.Options opts = Transport.Options.get( + "/v1/accounts/" + AccountsClient.encode(accountId) + "/transactions") + .query(params.toQuery()); + JsonNode node = transport.request(opts); + return Json.treeToValue(node, TransactionsPage.class); + } + + public Transaction get(String id) { + Objects.requireNonNull(id, "id"); + Transport.Options opts = Transport.Options.get( + "/v1/transactions/" + AccountsClient.encode(id)) + .cacheTtl(GET_CACHE); + return Json.treeToValue(transport.request(opts), Transaction.class); + } + + /** Cursor + date filter parameters for {@link #listForAccount(String, ListParams)}. */ + public static final class ListParams { + public String startDate; + public String endDate; + public String scope; + public Integer page; + public Integer perPage; + public String transactionsAfterId; + public String transactionsBeforeId; + + public ListParams startDate(String v) { this.startDate = v; return this; } + public ListParams endDate(String v) { this.endDate = v; return this; } + public ListParams scope(String v) { this.scope = v; return this; } + public ListParams page(int v) { this.page = v; return this; } + public ListParams perPage(int v) { this.perPage = v; return this; } + public ListParams transactionsAfterId(String v) { this.transactionsAfterId = v; return this; } + public ListParams transactionsBeforeId(String v) { this.transactionsBeforeId = v; return this; } + + java.util.Map toQuery() { + return QueryParams.of() + .put("start_date", startDate) + .put("end_date", endDate) + .put("scope", scope) + .put("page", page) + .put("per_page", perPage) + .put("transactions_after_id", transactionsAfterId) + .put("transactions_before_id", transactionsBeforeId) + .build(); + } + } +} diff --git a/packages/java/src/main/java/com/tesote/sdk/v1/V1Client.java b/packages/java/src/main/java/com/tesote/sdk/v1/V1Client.java index acc2d69..3c6b14c 100644 --- a/packages/java/src/main/java/com/tesote/sdk/v1/V1Client.java +++ b/packages/java/src/main/java/com/tesote/sdk/v1/V1Client.java @@ -10,17 +10,22 @@ /** * v1 client. Read-only foundation: status, accounts, transactions. * - *

0.1.0 ships the builder + transport plumbing; resource methods stub until - * they're wired in subsequent commits per the back-compat policy in - * {@code docs/architecture/versioning.md}. + *

Each accessor returns the same instance for the lifetime of the client. + * The client is thread-safe; share it. */ public final class V1Client { static final String VERSION_PATH = "/v1"; private final Transport transport; + private final StatusClient status; + private final AccountsClient accounts; + private final TransactionsClient transactions; private V1Client(Builder b) { this.transport = b.transportBuilder.build(); + this.status = new StatusClient(this.transport); + this.accounts = new AccountsClient(this.transport); + this.transactions = new TransactionsClient(this.transport); } public static Builder builder() { @@ -29,9 +34,9 @@ public static Builder builder() { public Transport transport() { return transport; } - public Object status() { throw new UnsupportedOperationException("not implemented"); } - public Object accounts() { throw new UnsupportedOperationException("not implemented"); } - public Object transactions() { throw new UnsupportedOperationException("not implemented"); } + public StatusClient status() { return status; } + public AccountsClient accounts() { return accounts; } + public TransactionsClient transactions() { return transactions; } public static final class Builder { private final Transport.Builder transportBuilder = Transport.builder(); diff --git a/packages/java/src/main/java/com/tesote/sdk/v2/AccountsClient.java b/packages/java/src/main/java/com/tesote/sdk/v2/AccountsClient.java new file mode 100644 index 0000000..2baac88 --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/v2/AccountsClient.java @@ -0,0 +1,84 @@ +package com.tesote.sdk.v2; + +import com.fasterxml.jackson.databind.JsonNode; +import com.tesote.sdk.Transport; +import com.tesote.sdk.internal.Json; +import com.tesote.sdk.internal.QueryParams; +import com.tesote.sdk.models.Account; +import com.tesote.sdk.models.AccountSyncResponse; +import com.tesote.sdk.models.AccountsPage; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Objects; + +/** + * v2 accounts: list + get (same shape as v1) plus a {@code sync} mutation. + */ +public final class AccountsClient { + private static final Duration LIST_CACHE = Duration.ofMinutes(1); + private static final Duration GET_CACHE = Duration.ofMinutes(5); + + private final Transport transport; + + public AccountsClient(Transport transport) { this.transport = transport; } + + public AccountsPage list() { return list(new ListParams()); } + + public AccountsPage list(ListParams params) { + Objects.requireNonNull(params, "params"); + Transport.Options opts = Transport.Options.get("/v2/accounts") + .query(QueryParams.of() + .put("page", params.page) + .put("per_page", params.perPage) + .put("include", params.include) + .put("sort", params.sort) + .build()) + .cacheTtl(LIST_CACHE); + JsonNode node = transport.request(opts); + return Json.treeToValue(node, AccountsPage.class); + } + + public Account get(String id) { + Objects.requireNonNull(id, "id"); + Transport.Options opts = Transport.Options.get("/v2/accounts/" + encode(id)) + .cacheTtl(GET_CACHE); + return Json.treeToValue(transport.request(opts), Account.class); + } + + /** + * Trigger an async bank sync for the account. The server returns 202 with + * a {@code sync_session_id} that can be polled via the SyncSessionsClient. + * + * @param idempotencyKey optional caller-supplied key for safe retries. + */ + public AccountSyncResponse sync(String id, String idempotencyKey) { + Objects.requireNonNull(id, "id"); + Transport.Options opts = new Transport.Options(); + opts.method = "POST"; + opts.path = "/v2/accounts/" + encode(id) + "/sync"; + opts.body = "{}".getBytes(StandardCharsets.UTF_8); + opts.bodyShape = "0 fields"; + if (idempotencyKey != null) opts.idempotencyKey(idempotencyKey); + return Json.treeToValue(transport.request(opts), AccountSyncResponse.class); + } + + public AccountSyncResponse sync(String id) { return sync(id, null); } + + static String encode(String segment) { + return URLEncoder.encode(segment, StandardCharsets.UTF_8); + } + + public static final class ListParams { + public Integer page; + public Integer perPage; + public String include; + public String sort; + + public ListParams page(int v) { this.page = v; return this; } + public ListParams perPage(int v) { this.perPage = v; return this; } + public ListParams include(String v) { this.include = v; return this; } + public ListParams sort(String v) { this.sort = v; return this; } + } +} diff --git a/packages/java/src/main/java/com/tesote/sdk/v2/BatchesClient.java b/packages/java/src/main/java/com/tesote/sdk/v2/BatchesClient.java new file mode 100644 index 0000000..ab7b53f --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/v2/BatchesClient.java @@ -0,0 +1,97 @@ +package com.tesote.sdk.v2; + +import com.tesote.sdk.Transport; +import com.tesote.sdk.internal.Json; +import com.tesote.sdk.models.BatchActionResponse; +import com.tesote.sdk.models.BatchCreateResponse; +import com.tesote.sdk.models.BatchSummary; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Multi-order batches scoped to a source account. + */ +public final class BatchesClient { + private final Transport transport; + + public BatchesClient(Transport transport) { this.transport = transport; } + + public BatchCreateResponse create(String accountId, CreateRequest request) { + Objects.requireNonNull(accountId, "accountId"); + Objects.requireNonNull(request, "request"); + Transport.Options opts = new Transport.Options(); + opts.method = "POST"; + opts.path = "/v2/accounts/" + AccountsClient.encode(accountId) + "/batches"; + opts.jsonBody(request.toMap()); + return Json.treeToValue(transport.request(opts), BatchCreateResponse.class); + } + + public BatchSummary show(String accountId, String batchId) { + Objects.requireNonNull(accountId, "accountId"); + Objects.requireNonNull(batchId, "batchId"); + Transport.Options opts = Transport.Options.get( + "/v2/accounts/" + AccountsClient.encode(accountId) + + "/batches/" + AccountsClient.encode(batchId)); + return Json.treeToValue(transport.request(opts), BatchSummary.class); + } + + public BatchActionResponse approve(String accountId, String batchId) { + return mutate(accountId, batchId, "approve", null); + } + + public BatchActionResponse submit(String accountId, String batchId) { + return submit(accountId, batchId, null); + } + + public BatchActionResponse submit(String accountId, String batchId, String token) { + Map body = new LinkedHashMap<>(); + if (token != null) body.put("token", token); + return mutate(accountId, batchId, "submit", body); + } + + public BatchActionResponse cancel(String accountId, String batchId) { + return mutate(accountId, batchId, "cancel", null); + } + + private BatchActionResponse mutate(String accountId, String batchId, String action, + Map body) { + Objects.requireNonNull(accountId, "accountId"); + Objects.requireNonNull(batchId, "batchId"); + Transport.Options opts = new Transport.Options(); + opts.method = "POST"; + opts.path = "/v2/accounts/" + AccountsClient.encode(accountId) + + "/batches/" + AccountsClient.encode(batchId) + "/" + action; + if (body == null || body.isEmpty()) { + opts.body = "{}".getBytes(StandardCharsets.UTF_8); + opts.bodyShape = "0 fields"; + } else { + opts.jsonBody(body); + } + return Json.treeToValue(transport.request(opts), BatchActionResponse.class); + } + + public static final class CreateRequest { + public List orders = new ArrayList<>(); + + public CreateRequest orders(List v) { + this.orders = v; return this; + } + + public CreateRequest add(TransactionOrdersClient.CreateRequest order) { + this.orders.add(order); return this; + } + + Map toMap() { + Map m = new LinkedHashMap<>(); + List> wire = new ArrayList<>(orders.size()); + for (TransactionOrdersClient.CreateRequest o : orders) wire.add(o.toMap()); + m.put("orders", wire); + return m; + } + } +} diff --git a/packages/java/src/main/java/com/tesote/sdk/v2/PaymentMethodsClient.java b/packages/java/src/main/java/com/tesote/sdk/v2/PaymentMethodsClient.java new file mode 100644 index 0000000..1eaf2d3 --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/v2/PaymentMethodsClient.java @@ -0,0 +1,160 @@ +package com.tesote.sdk.v2; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.tesote.sdk.Transport; +import com.tesote.sdk.internal.Json; +import com.tesote.sdk.internal.QueryParams; +import com.tesote.sdk.models.OffsetPage; +import com.tesote.sdk.models.PaymentMethod; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Payment methods (workspace-scoped). CRUD + soft-delete. + */ +public final class PaymentMethodsClient { + private final Transport transport; + + public PaymentMethodsClient(Transport transport) { this.transport = transport; } + + public OffsetPage list() { return list(new ListParams()); } + + public OffsetPage list(ListParams params) { + Objects.requireNonNull(params, "params"); + Transport.Options opts = Transport.Options.get("/v2/payment_methods") + .query(QueryParams.of() + .put("limit", params.limit) + .put("offset", params.offset) + .put("method_type", params.methodType) + .put("currency", params.currency) + .put("counterparty_id", params.counterpartyId) + .put("verified", params.verified) + .build()); + return Json.treeToValue(transport.request(opts), + new TypeReference>() {}); + } + + public PaymentMethod get(String id) { + Objects.requireNonNull(id, "id"); + Transport.Options opts = Transport.Options.get( + "/v2/payment_methods/" + AccountsClient.encode(id)); + return Json.treeToValue(transport.request(opts), PaymentMethod.class); + } + + public PaymentMethod create(CreateRequest request) { + Objects.requireNonNull(request, "request"); + Transport.Options opts = new Transport.Options(); + opts.method = "POST"; + opts.path = "/v2/payment_methods"; + Map envelope = new LinkedHashMap<>(); + envelope.put("payment_method", request.toMap()); + opts.jsonBody(envelope); + return Json.treeToValue(transport.request(opts), PaymentMethod.class); + } + + public PaymentMethod update(String id, UpdateRequest request) { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(request, "request"); + Transport.Options opts = new Transport.Options(); + opts.method = "PATCH"; + opts.path = "/v2/payment_methods/" + AccountsClient.encode(id); + Map envelope = new LinkedHashMap<>(); + envelope.put("payment_method", request.toMap()); + opts.jsonBody(envelope); + return Json.treeToValue(transport.request(opts), PaymentMethod.class); + } + + public void delete(String id) { + Objects.requireNonNull(id, "id"); + Transport.Options opts = new Transport.Options(); + opts.method = "DELETE"; + opts.path = "/v2/payment_methods/" + AccountsClient.encode(id); + transport.request(opts); + } + + public static final class ListParams { + public Integer limit; + public Integer offset; + public String methodType; + public String currency; + public String counterpartyId; + public Boolean verified; + + public ListParams limit(int v) { this.limit = v; return this; } + public ListParams offset(int v) { this.offset = v; return this; } + public ListParams methodType(String v) { this.methodType = v; return this; } + public ListParams currency(String v) { this.currency = v; return this; } + public ListParams counterpartyId(String v) { this.counterpartyId = v; return this; } + public ListParams verified(boolean v) { this.verified = v; return this; } + } + + /** Body for {@link #create(CreateRequest)}. */ + public static final class CreateRequest { + public String methodType; + public String currency; + public String label; + public String counterpartyId; + public Counterparty counterparty; + public Map details; + + public CreateRequest methodType(String v) { this.methodType = v; return this; } + public CreateRequest currency(String v) { this.currency = v; return this; } + public CreateRequest label(String v) { this.label = v; return this; } + public CreateRequest counterpartyId(String v) { this.counterpartyId = v; return this; } + public CreateRequest counterparty(Counterparty v) { this.counterparty = v; return this; } + public CreateRequest details(Map v) { this.details = v; return this; } + + Map toMap() { + Map m = new LinkedHashMap<>(); + if (methodType != null) m.put("method_type", methodType); + if (currency != null) m.put("currency", currency); + if (label != null) m.put("label", label); + if (counterpartyId != null) m.put("counterparty_id", counterpartyId); + if (counterparty != null) m.put("counterparty", counterparty.toMap()); + if (details != null) m.put("details", details); + return m; + } + } + + /** Body for {@link #update(String, UpdateRequest)}; same shape as create, all optional. */ + public static final class UpdateRequest { + public String methodType; + public String currency; + public String label; + public String counterpartyId; + public Counterparty counterparty; + public Map details; + + public UpdateRequest methodType(String v) { this.methodType = v; return this; } + public UpdateRequest currency(String v) { this.currency = v; return this; } + public UpdateRequest label(String v) { this.label = v; return this; } + public UpdateRequest counterpartyId(String v) { this.counterpartyId = v; return this; } + public UpdateRequest counterparty(Counterparty v) { this.counterparty = v; return this; } + public UpdateRequest details(Map v) { this.details = v; return this; } + + Map toMap() { + Map m = new LinkedHashMap<>(); + if (methodType != null) m.put("method_type", methodType); + if (currency != null) m.put("currency", currency); + if (label != null) m.put("label", label); + if (counterpartyId != null) m.put("counterparty_id", counterpartyId); + if (counterparty != null) m.put("counterparty", counterparty.toMap()); + if (details != null) m.put("details", details); + return m; + } + } + + public static final class Counterparty { + public String name; + + public Counterparty name(String v) { this.name = v; return this; } + + Map toMap() { + Map m = new LinkedHashMap<>(); + if (name != null) m.put("name", name); + return m; + } + } +} diff --git a/packages/java/src/main/java/com/tesote/sdk/v2/StatusClient.java b/packages/java/src/main/java/com/tesote/sdk/v2/StatusClient.java new file mode 100644 index 0000000..d8fb0d3 --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/v2/StatusClient.java @@ -0,0 +1,26 @@ +package com.tesote.sdk.v2; + +import com.fasterxml.jackson.databind.JsonNode; +import com.tesote.sdk.Transport; +import com.tesote.sdk.internal.Json; +import com.tesote.sdk.models.Status; +import com.tesote.sdk.models.Whoami; + +/** + * v2 status + whoami. Mirrors v1; separate path prefix. + */ +public final class StatusClient { + private final Transport transport; + + public StatusClient(Transport transport) { this.transport = transport; } + + public Status status() { + JsonNode node = transport.request(Transport.Options.get("/v2/status")); + return Json.treeToValue(node, Status.class); + } + + public Whoami whoami() { + JsonNode node = transport.request(Transport.Options.get("/v2/whoami")); + return Json.treeToValue(node, Whoami.class); + } +} diff --git a/packages/java/src/main/java/com/tesote/sdk/v2/SyncSessionsClient.java b/packages/java/src/main/java/com/tesote/sdk/v2/SyncSessionsClient.java new file mode 100644 index 0000000..b0419bc --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/v2/SyncSessionsClient.java @@ -0,0 +1,56 @@ +package com.tesote.sdk.v2; + +import com.fasterxml.jackson.databind.JsonNode; +import com.tesote.sdk.Transport; +import com.tesote.sdk.internal.Json; +import com.tesote.sdk.internal.QueryParams; +import com.tesote.sdk.models.SyncSession; +import com.tesote.sdk.models.SyncSessionsPage; + +import java.util.Objects; + +/** + * Read-only access to per-account sync sessions. + */ +public final class SyncSessionsClient { + private final Transport transport; + + public SyncSessionsClient(Transport transport) { this.transport = transport; } + + public SyncSessionsPage list(String accountId) { + return list(accountId, new ListParams()); + } + + public SyncSessionsPage list(String accountId, ListParams params) { + Objects.requireNonNull(accountId, "accountId"); + Objects.requireNonNull(params, "params"); + Transport.Options opts = Transport.Options.get( + "/v2/accounts/" + AccountsClient.encode(accountId) + "/sync_sessions") + .query(QueryParams.of() + .put("limit", params.limit) + .put("offset", params.offset) + .put("status", params.status) + .build()); + JsonNode node = transport.request(opts); + return Json.treeToValue(node, SyncSessionsPage.class); + } + + public SyncSession get(String accountId, String sessionId) { + Objects.requireNonNull(accountId, "accountId"); + Objects.requireNonNull(sessionId, "sessionId"); + Transport.Options opts = Transport.Options.get( + "/v2/accounts/" + AccountsClient.encode(accountId) + + "/sync_sessions/" + AccountsClient.encode(sessionId)); + return Json.treeToValue(transport.request(opts), SyncSession.class); + } + + public static final class ListParams { + public Integer limit; + public Integer offset; + public String status; + + public ListParams limit(int v) { this.limit = v; return this; } + public ListParams offset(int v) { this.offset = v; return this; } + public ListParams status(String v) { this.status = v; return this; } + } +} diff --git a/packages/java/src/main/java/com/tesote/sdk/v2/TransactionOrdersClient.java b/packages/java/src/main/java/com/tesote/sdk/v2/TransactionOrdersClient.java new file mode 100644 index 0000000..7022168 --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/v2/TransactionOrdersClient.java @@ -0,0 +1,181 @@ +package com.tesote.sdk.v2; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.tesote.sdk.Transport; +import com.tesote.sdk.internal.Json; +import com.tesote.sdk.internal.QueryParams; +import com.tesote.sdk.models.OffsetPage; +import com.tesote.sdk.models.TransactionOrder; + +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Manage transaction orders: read, create (draft), submit, cancel. + */ +public final class TransactionOrdersClient { + private final Transport transport; + + public TransactionOrdersClient(Transport transport) { this.transport = transport; } + + public OffsetPage list(String accountId) { + return list(accountId, new ListParams()); + } + + public OffsetPage list(String accountId, ListParams params) { + Objects.requireNonNull(accountId, "accountId"); + Objects.requireNonNull(params, "params"); + Transport.Options opts = Transport.Options.get( + "/v2/accounts/" + AccountsClient.encode(accountId) + "/transaction_orders") + .query(QueryParams.of() + .put("limit", params.limit) + .put("offset", params.offset) + .put("status", params.status) + .put("created_after", params.createdAfter) + .put("created_before", params.createdBefore) + .put("batch_id", params.batchId) + .build()); + JsonNode node = transport.request(opts); + return Json.treeToValue(node, new TypeReference>() {}); + } + + public TransactionOrder get(String accountId, String orderId) { + Objects.requireNonNull(accountId, "accountId"); + Objects.requireNonNull(orderId, "orderId"); + Transport.Options opts = Transport.Options.get( + "/v2/accounts/" + AccountsClient.encode(accountId) + + "/transaction_orders/" + AccountsClient.encode(orderId)); + return Json.treeToValue(transport.request(opts), TransactionOrder.class); + } + + /** + * Create a draft order. If {@code request.idempotencyKey} is set the + * server returns the existing order on retry; we also forward it as the + * {@code Idempotency-Key} HTTP header so transport-level retries are safe. + */ + public TransactionOrder create(String accountId, CreateRequest request) { + Objects.requireNonNull(accountId, "accountId"); + Objects.requireNonNull(request, "request"); + Transport.Options opts = new Transport.Options(); + opts.method = "POST"; + opts.path = "/v2/accounts/" + AccountsClient.encode(accountId) + "/transaction_orders"; + Map envelope = new LinkedHashMap<>(); + envelope.put("transaction_order", request.toMap()); + opts.jsonBody(envelope); + if (request.idempotencyKey != null) { + opts.idempotencyKey(request.idempotencyKey); + } + return Json.treeToValue(transport.request(opts), TransactionOrder.class); + } + + public TransactionOrder submit(String accountId, String orderId) { + return submit(accountId, orderId, null); + } + + public TransactionOrder submit(String accountId, String orderId, String token) { + Objects.requireNonNull(accountId, "accountId"); + Objects.requireNonNull(orderId, "orderId"); + Transport.Options opts = new Transport.Options(); + opts.method = "POST"; + opts.path = "/v2/accounts/" + AccountsClient.encode(accountId) + + "/transaction_orders/" + AccountsClient.encode(orderId) + "/submit"; + Map body = new LinkedHashMap<>(); + if (token != null) body.put("token", token); + opts.jsonBody(body); + return Json.treeToValue(transport.request(opts), TransactionOrder.class); + } + + public TransactionOrder cancel(String accountId, String orderId) { + Objects.requireNonNull(accountId, "accountId"); + Objects.requireNonNull(orderId, "orderId"); + Transport.Options opts = new Transport.Options(); + opts.method = "POST"; + opts.path = "/v2/accounts/" + AccountsClient.encode(accountId) + + "/transaction_orders/" + AccountsClient.encode(orderId) + "/cancel"; + opts.body = "{}".getBytes(StandardCharsets.UTF_8); + opts.bodyShape = "0 fields"; + return Json.treeToValue(transport.request(opts), TransactionOrder.class); + } + + public static final class ListParams { + public Integer limit; + public Integer offset; + public String status; + public String createdAfter; + public String createdBefore; + public String batchId; + + public ListParams limit(int v) { this.limit = v; return this; } + public ListParams offset(int v) { this.offset = v; return this; } + public ListParams status(String v) { this.status = v; return this; } + public ListParams createdAfter(String v) { this.createdAfter = v; return this; } + public ListParams createdBefore(String v) { this.createdBefore = v; return this; } + public ListParams batchId(String v) { this.batchId = v; return this; } + } + + /** Body for {@link #create(String, CreateRequest)}. */ + public static final class CreateRequest { + public String destinationPaymentMethodId; + public Beneficiary beneficiary; + public String amount; + public String currency; + public String description; + public String scheduledFor; + public String idempotencyKey; + public Map metadata; + + public CreateRequest destinationPaymentMethodId(String v) { + this.destinationPaymentMethodId = v; return this; + } + public CreateRequest beneficiary(Beneficiary v) { this.beneficiary = v; return this; } + public CreateRequest amount(String v) { this.amount = v; return this; } + public CreateRequest currency(String v) { this.currency = v; return this; } + public CreateRequest description(String v) { this.description = v; return this; } + public CreateRequest scheduledFor(String v) { this.scheduledFor = v; return this; } + public CreateRequest idempotencyKey(String v) { this.idempotencyKey = v; return this; } + public CreateRequest metadata(Map v) { this.metadata = v; return this; } + + Map toMap() { + Map m = new LinkedHashMap<>(); + if (destinationPaymentMethodId != null) { + m.put("destination_payment_method_id", destinationPaymentMethodId); + } + if (beneficiary != null) m.put("beneficiary", beneficiary.toMap()); + if (amount != null) m.put("amount", amount); + if (currency != null) m.put("currency", currency); + if (description != null) m.put("description", description); + if (scheduledFor != null) m.put("scheduled_for", scheduledFor); + if (idempotencyKey != null) m.put("idempotency_key", idempotencyKey); + if (metadata != null) m.put("metadata", metadata); + return m; + } + } + + /** Inline beneficiary (creates a payment method server-side on first use). */ + public static final class Beneficiary { + public String name; + public String bankCode; + public String accountNumber; + public String identificationType; + public String identificationNumber; + + public Beneficiary name(String v) { this.name = v; return this; } + public Beneficiary bankCode(String v) { this.bankCode = v; return this; } + public Beneficiary accountNumber(String v) { this.accountNumber = v; return this; } + public Beneficiary identificationType(String v) { this.identificationType = v; return this; } + public Beneficiary identificationNumber(String v) { this.identificationNumber = v; return this; } + + Map toMap() { + Map m = new LinkedHashMap<>(); + if (name != null) m.put("name", name); + if (bankCode != null) m.put("bank_code", bankCode); + if (accountNumber != null) m.put("account_number", accountNumber); + if (identificationType != null) m.put("identification_type", identificationType); + if (identificationNumber != null) m.put("identification_number", identificationNumber); + return m; + } + } +} diff --git a/packages/java/src/main/java/com/tesote/sdk/v2/TransactionsClient.java b/packages/java/src/main/java/com/tesote/sdk/v2/TransactionsClient.java new file mode 100644 index 0000000..18237cb --- /dev/null +++ b/packages/java/src/main/java/com/tesote/sdk/v2/TransactionsClient.java @@ -0,0 +1,286 @@ +package com.tesote.sdk.v2; + +import com.fasterxml.jackson.databind.JsonNode; +import com.tesote.sdk.Transport; +import com.tesote.sdk.internal.Json; +import com.tesote.sdk.internal.QueryParams; +import com.tesote.sdk.models.BulkResponse; +import com.tesote.sdk.models.SyncTransactionsResponse; +import com.tesote.sdk.models.Transaction; +import com.tesote.sdk.models.TransactionsExport; +import com.tesote.sdk.models.TransactionsPage; +import com.tesote.sdk.models.TransactionsSearchResponse; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * v2 transactions: nested list, sync (cursor + legacy), get, bulk read, + * search, export. + */ +public final class TransactionsClient { + private static final Duration GET_CACHE = Duration.ofMinutes(5); + private static final Duration LIST_CACHE = Duration.ofMinutes(1); + + private final Transport transport; + + public TransactionsClient(Transport transport) { this.transport = transport; } + + public TransactionsPage listForAccount(String accountId) { + return listForAccount(accountId, new ListParams()); + } + + public TransactionsPage listForAccount(String accountId, ListParams params) { + Objects.requireNonNull(accountId, "accountId"); + Objects.requireNonNull(params, "params"); + Transport.Options opts = Transport.Options.get( + "/v2/accounts/" + AccountsClient.encode(accountId) + "/transactions") + .query(params.toQuery()) + .cacheTtl(LIST_CACHE); + return Json.treeToValue(transport.request(opts), TransactionsPage.class); + } + + public Transaction get(String id) { + Objects.requireNonNull(id, "id"); + Transport.Options opts = Transport.Options.get( + "/v2/transactions/" + AccountsClient.encode(id)) + .cacheTtl(GET_CACHE); + return Json.treeToValue(transport.request(opts), Transaction.class); + } + + /** Cursor-based incremental sync scoped to one account. */ + public SyncTransactionsResponse sync(String accountId, SyncRequest request) { + Objects.requireNonNull(accountId, "accountId"); + Objects.requireNonNull(request, "request"); + Transport.Options opts = new Transport.Options(); + opts.method = "POST"; + opts.path = "/v2/accounts/" + AccountsClient.encode(accountId) + "/transactions/sync"; + opts.jsonBody(request.toMap()); + return Json.treeToValue(transport.request(opts), SyncTransactionsResponse.class); + } + + /** Legacy non-nested sync. Caller specifies account context inside the body. */ + public SyncTransactionsResponse syncLegacy(SyncRequest request) { + Objects.requireNonNull(request, "request"); + Transport.Options opts = new Transport.Options(); + opts.method = "POST"; + opts.path = "/v2/transactions/sync"; + opts.jsonBody(request.toMap()); + return Json.treeToValue(transport.request(opts), SyncTransactionsResponse.class); + } + + /** Fetch transactions across multiple accounts in one round-trip. */ + public BulkResponse bulk(BulkRequest request) { + Objects.requireNonNull(request, "request"); + Transport.Options opts = new Transport.Options(); + opts.method = "POST"; + opts.path = "/v2/transactions/bulk"; + opts.jsonBody(request.toMap()); + return Json.treeToValue(transport.request(opts), BulkResponse.class); + } + + public TransactionsSearchResponse search(SearchParams params) { + Objects.requireNonNull(params, "params"); + Transport.Options opts = Transport.Options.get("/v2/transactions/search") + .query(params.toQuery()); + return Json.treeToValue(transport.request(opts), TransactionsSearchResponse.class); + } + + /** + * Download a CSV or JSON export of transactions for an account. Returns + * the raw bytes plus the server-provided filename when present. + */ + public TransactionsExport export(String accountId, ExportParams params) { + Objects.requireNonNull(accountId, "accountId"); + Objects.requireNonNull(params, "params"); + Transport.Options opts = new Transport.Options(); + opts.method = "GET"; + opts.path = "/v2/accounts/" + AccountsClient.encode(accountId) + "/transactions/export"; + opts.query = params.toQuery(); + Transport.RawResponse raw = transport.requestRaw(opts); + return new TransactionsExport( + raw.body(), + parseFilename(raw.contentDisposition()), + raw.contentType()); + } + + private static String parseFilename(String contentDisposition) { + if (contentDisposition == null) return null; + // why: "attachment; filename=foo.csv" or filename*= variants. + for (String part : contentDisposition.split(";")) { + String trim = part.trim(); + if (trim.startsWith("filename=")) { + String v = trim.substring("filename=".length()).trim(); + if (v.startsWith("\"") && v.endsWith("\"") && v.length() >= 2) { + v = v.substring(1, v.length() - 1); + } + return v; + } + } + return null; + } + + /** Body for sync / syncLegacy. */ + public static final class SyncRequest { + public Integer count; + public String cursor; + public Boolean includeRunningBalance; + + public SyncRequest count(int v) { this.count = v; return this; } + public SyncRequest cursor(String v) { this.cursor = v; return this; } + public SyncRequest includeRunningBalance(boolean v) { this.includeRunningBalance = v; return this; } + + Map toMap() { + Map m = new LinkedHashMap<>(); + if (count != null) m.put("count", count); + if (cursor != null) m.put("cursor", cursor); + if (includeRunningBalance != null) { + Map opts = new LinkedHashMap<>(); + opts.put("include_running_balance", includeRunningBalance); + m.put("options", opts); + } + return m; + } + } + + /** Body for {@link #bulk(BulkRequest)}. */ + public static final class BulkRequest { + public List accountIds; + public Integer page; + public Integer perPage; + public Integer limit; + public Integer offset; + + public BulkRequest accountIds(List v) { this.accountIds = v; return this; } + public BulkRequest page(int v) { this.page = v; return this; } + public BulkRequest perPage(int v) { this.perPage = v; return this; } + public BulkRequest limit(int v) { this.limit = v; return this; } + public BulkRequest offset(int v) { this.offset = v; return this; } + + Map toMap() { + Map m = new LinkedHashMap<>(); + if (accountIds != null) m.put("account_ids", accountIds); + if (page != null) m.put("page", page); + if (perPage != null) m.put("per_page", perPage); + if (limit != null) m.put("limit", limit); + if (offset != null) m.put("offset", offset); + return m; + } + } + + /** Search filters; reuses the listForAccount filter shape. */ + public static final class SearchParams { + public String q; + public String accountId; + public Integer limit; + public Integer offset; + public ListParams filters = new ListParams(); + + public SearchParams q(String v) { this.q = v; return this; } + public SearchParams accountId(String v) { this.accountId = v; return this; } + public SearchParams limit(int v) { this.limit = v; return this; } + public SearchParams offset(int v) { this.offset = v; return this; } + public SearchParams filters(ListParams v) { this.filters = v; return this; } + + Map toQuery() { + Map base = filters == null ? Map.of() : filters.toQuery(); + Map result = new LinkedHashMap<>(base); + QueryParams qp = QueryParams.of() + .put("q", q) + .put("account_id", accountId) + .put("limit", limit) + .put("offset", offset); + qp.build().forEach(result::put); + return result; + } + } + + /** Export query params: list filters plus {@code format}. */ + public static final class ExportParams { + public ListParams filters = new ListParams(); + public TransactionsExport.Format format; + + public ExportParams filters(ListParams v) { this.filters = v; return this; } + public ExportParams format(TransactionsExport.Format f) { this.format = f; return this; } + + Map toQuery() { + Map base = filters == null ? Map.of() : filters.toQuery(); + Map result = new LinkedHashMap<>(base); + if (format != null) result.put("format", format.wire()); + return result; + } + } + + /** Full v2 transaction list filter set (used by list, search, export). */ + public static final class ListParams { + public String startDate; + public String endDate; + public String scope; + public Integer page; + public Integer perPage; + public String transactionsAfterId; + public String transactionsBeforeId; + public String transactionDateAfter; + public String transactionDateBefore; + public String createdAfter; + public String updatedAfter; + public String amountMin; + public String amountMax; + public String amount; + public String status; + public String categoryId; + public String counterpartyId; + public String q; + public String type; + public String referenceCode; + + public ListParams startDate(String v) { this.startDate = v; return this; } + public ListParams endDate(String v) { this.endDate = v; return this; } + public ListParams scope(String v) { this.scope = v; return this; } + public ListParams page(int v) { this.page = v; return this; } + public ListParams perPage(int v) { this.perPage = v; return this; } + public ListParams transactionsAfterId(String v) { this.transactionsAfterId = v; return this; } + public ListParams transactionsBeforeId(String v) { this.transactionsBeforeId = v; return this; } + public ListParams transactionDateAfter(String v) { this.transactionDateAfter = v; return this; } + public ListParams transactionDateBefore(String v) { this.transactionDateBefore = v; return this; } + public ListParams createdAfter(String v) { this.createdAfter = v; return this; } + public ListParams updatedAfter(String v) { this.updatedAfter = v; return this; } + public ListParams amountMin(String v) { this.amountMin = v; return this; } + public ListParams amountMax(String v) { this.amountMax = v; return this; } + public ListParams amount(String v) { this.amount = v; return this; } + public ListParams status(String v) { this.status = v; return this; } + public ListParams categoryId(String v) { this.categoryId = v; return this; } + public ListParams counterpartyId(String v) { this.counterpartyId = v; return this; } + public ListParams q(String v) { this.q = v; return this; } + public ListParams type(String v) { this.type = v; return this; } + public ListParams referenceCode(String v) { this.referenceCode = v; return this; } + + Map toQuery() { + return QueryParams.of() + .put("start_date", startDate) + .put("end_date", endDate) + .put("scope", scope) + .put("page", page) + .put("per_page", perPage) + .put("transactions_after_id", transactionsAfterId) + .put("transactions_before_id", transactionsBeforeId) + .put("transaction_date_after", transactionDateAfter) + .put("transaction_date_before", transactionDateBefore) + .put("created_after", createdAfter) + .put("updated_after", updatedAfter) + .put("amount_min", amountMin) + .put("amount_max", amountMax) + .put("amount", amount) + .put("status", status) + .put("category_id", categoryId) + .put("counterparty_id", counterpartyId) + .put("q", q) + .put("type", type) + .put("reference_code", referenceCode) + .build(); + } + } +} diff --git a/packages/java/src/main/java/com/tesote/sdk/v2/V2Client.java b/packages/java/src/main/java/com/tesote/sdk/v2/V2Client.java index 777d7d3..eb79f95 100644 --- a/packages/java/src/main/java/com/tesote/sdk/v2/V2Client.java +++ b/packages/java/src/main/java/com/tesote/sdk/v2/V2Client.java @@ -10,16 +10,30 @@ /** * v2 client. Adds writes for payments + sync orchestration on top of v1. * - *

0.1.0 ships the builder + transport plumbing; resource methods stub until - * they're wired in subsequent commits. + *

Each accessor returns the same instance for the lifetime of the client. + * The client is thread-safe; share it. */ public final class V2Client { static final String VERSION_PATH = "/v2"; private final Transport transport; + private final StatusClient status; + private final AccountsClient accounts; + private final TransactionsClient transactions; + private final SyncSessionsClient syncSessions; + private final TransactionOrdersClient transactionOrders; + private final BatchesClient batches; + private final PaymentMethodsClient paymentMethods; private V2Client(Builder b) { this.transport = b.transportBuilder.build(); + this.status = new StatusClient(this.transport); + this.accounts = new AccountsClient(this.transport); + this.transactions = new TransactionsClient(this.transport); + this.syncSessions = new SyncSessionsClient(this.transport); + this.transactionOrders = new TransactionOrdersClient(this.transport); + this.batches = new BatchesClient(this.transport); + this.paymentMethods = new PaymentMethodsClient(this.transport); } public static Builder builder() { @@ -28,13 +42,13 @@ public static Builder builder() { public Transport transport() { return transport; } - public Object status() { throw new UnsupportedOperationException("not implemented"); } - public Object accounts() { throw new UnsupportedOperationException("not implemented"); } - public Object transactions() { throw new UnsupportedOperationException("not implemented"); } - public Object syncSessions() { throw new UnsupportedOperationException("not implemented"); } - public Object transactionOrders() { throw new UnsupportedOperationException("not implemented"); } - public Object batches() { throw new UnsupportedOperationException("not implemented"); } - public Object paymentMethods() { throw new UnsupportedOperationException("not implemented"); } + public StatusClient status() { return status; } + public AccountsClient accounts() { return accounts; } + public TransactionsClient transactions() { return transactions; } + public SyncSessionsClient syncSessions() { return syncSessions; } + public TransactionOrdersClient transactionOrders() { return transactionOrders; } + public BatchesClient batches() { return batches; } + public PaymentMethodsClient paymentMethods() { return paymentMethods; } public static final class Builder { private final Transport.Builder transportBuilder = Transport.builder(); diff --git a/packages/java/src/test/java/com/tesote/sdk/ErrorsTest.java b/packages/java/src/test/java/com/tesote/sdk/ErrorsTest.java index 32b55a8..08c64c6 100644 --- a/packages/java/src/test/java/com/tesote/sdk/ErrorsTest.java +++ b/packages/java/src/test/java/com/tesote/sdk/ErrorsTest.java @@ -1,17 +1,38 @@ package com.tesote.sdk; import com.tesote.sdk.errors.AccountDisabledException; +import com.tesote.sdk.errors.AccountNotFoundException; import com.tesote.sdk.errors.ApiException; import com.tesote.sdk.errors.ApiKeyRevokedException; +import com.tesote.sdk.errors.BankConnectionNotFoundException; +import com.tesote.sdk.errors.BankSubmissionException; +import com.tesote.sdk.errors.BankUnderMaintenanceException; +import com.tesote.sdk.errors.BatchNotFoundException; +import com.tesote.sdk.errors.BatchValidationException; import com.tesote.sdk.errors.ErrorDispatcher; import com.tesote.sdk.errors.HistorySyncForbiddenException; +import com.tesote.sdk.errors.InternalErrorException; +import com.tesote.sdk.errors.InvalidCountException; +import com.tesote.sdk.errors.InvalidCursorException; import com.tesote.sdk.errors.InvalidDateRangeException; +import com.tesote.sdk.errors.InvalidLimitException; +import com.tesote.sdk.errors.InvalidOrderStateException; +import com.tesote.sdk.errors.InvalidQueryException; +import com.tesote.sdk.errors.MissingDateRangeException; import com.tesote.sdk.errors.MutationDuringPaginationException; +import com.tesote.sdk.errors.NotFoundException; +import com.tesote.sdk.errors.PaymentMethodNotFoundException; import com.tesote.sdk.errors.RateLimitExceededException; import com.tesote.sdk.errors.RequestSummary; import com.tesote.sdk.errors.ServiceUnavailableException; +import com.tesote.sdk.errors.SyncInProgressException; +import com.tesote.sdk.errors.SyncRateLimitExceededException; +import com.tesote.sdk.errors.SyncSessionNotFoundException; +import com.tesote.sdk.errors.TransactionNotFoundException; +import com.tesote.sdk.errors.TransactionOrderNotFoundException; import com.tesote.sdk.errors.UnauthorizedException; import com.tesote.sdk.errors.UnprocessableContentException; +import com.tesote.sdk.errors.ValidationException; import com.tesote.sdk.errors.WorkspaceSuspendedException; import org.junit.jupiter.api.Test; @@ -117,4 +138,44 @@ void shortKeyStillRedacted() { String redacted = Transport.redactBearer("ab"); assertEquals("Bearer ****", redacted); } + + @Test + void notFoundFamilyAllSubclassNotFound() { + assertInstanceOf(AccountNotFoundException.class, dispatch("ACCOUNT_NOT_FOUND", 404)); + assertInstanceOf(NotFoundException.class, dispatch("ACCOUNT_NOT_FOUND", 404)); + assertInstanceOf(TransactionNotFoundException.class, dispatch("TRANSACTION_NOT_FOUND", 404)); + assertInstanceOf(NotFoundException.class, dispatch("TRANSACTION_NOT_FOUND", 404)); + assertInstanceOf(SyncSessionNotFoundException.class, dispatch("SYNC_SESSION_NOT_FOUND", 404)); + assertInstanceOf(PaymentMethodNotFoundException.class, dispatch("PAYMENT_METHOD_NOT_FOUND", 404)); + assertInstanceOf(TransactionOrderNotFoundException.class, dispatch("TRANSACTION_ORDER_NOT_FOUND", 404)); + assertInstanceOf(BatchNotFoundException.class, dispatch("BATCH_NOT_FOUND", 404)); + assertInstanceOf(BankConnectionNotFoundException.class, dispatch("BANK_CONNECTION_NOT_FOUND", 404)); + } + + @Test + void unprocessableFamilyAllSubclassUnprocessable() { + assertInstanceOf(InvalidCursorException.class, dispatch("INVALID_CURSOR", 422)); + assertInstanceOf(UnprocessableContentException.class, dispatch("INVALID_CURSOR", 422)); + assertInstanceOf(InvalidCountException.class, dispatch("INVALID_COUNT", 422)); + assertInstanceOf(InvalidLimitException.class, dispatch("INVALID_LIMIT", 422)); + assertInstanceOf(InvalidQueryException.class, dispatch("INVALID_QUERY", 422)); + assertInstanceOf(MissingDateRangeException.class, dispatch("MISSING_DATE_RANGE", 422)); + assertInstanceOf(BankSubmissionException.class, dispatch("BANK_SUBMISSION_ERROR", 422)); + } + + @Test + void validationFamilyAllSubclassValidation() { + assertInstanceOf(ValidationException.class, dispatch("VALIDATION_ERROR", 400)); + assertInstanceOf(BatchValidationException.class, dispatch("BATCH_VALIDATION_ERROR", 400)); + assertInstanceOf(ValidationException.class, dispatch("BATCH_VALIDATION_ERROR", 400)); + } + + @Test + void conflictAndSyncErrorsMap() { + assertInstanceOf(InvalidOrderStateException.class, dispatch("INVALID_ORDER_STATE", 409)); + assertInstanceOf(SyncInProgressException.class, dispatch("SYNC_IN_PROGRESS", 409)); + assertInstanceOf(SyncRateLimitExceededException.class, dispatch("SYNC_RATE_LIMIT_EXCEEDED", 429)); + assertInstanceOf(BankUnderMaintenanceException.class, dispatch("BANK_UNDER_MAINTENANCE", 503)); + assertInstanceOf(InternalErrorException.class, dispatch("INTERNAL_ERROR", 500)); + } } diff --git a/packages/java/src/test/java/com/tesote/sdk/v1/V1AccountsClientTest.java b/packages/java/src/test/java/com/tesote/sdk/v1/V1AccountsClientTest.java new file mode 100644 index 0000000..e18e4fc --- /dev/null +++ b/packages/java/src/test/java/com/tesote/sdk/v1/V1AccountsClientTest.java @@ -0,0 +1,102 @@ +package com.tesote.sdk.v1; + +import com.tesote.sdk.errors.AccountNotFoundException; +import com.tesote.sdk.errors.UnauthorizedException; +import com.tesote.sdk.models.Account; +import com.tesote.sdk.models.AccountsPage; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class V1AccountsClientTest { + private MockWebServer server; + private V1Client client; + + @BeforeEach + void setUp() throws IOException { + server = new MockWebServer(); + server.start(); + client = V1Client.builder() + .apiKey("sk_test_abcd1234") + .baseUrl(server.url("/api").toString()) + .build(); + } + + @AfterEach + void tearDown() throws IOException { server.shutdown(); } + + @Test + void listSendsPageQueryAndDeserializes() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200) + .setBody("{\"total\":1,\"accounts\":[{\"id\":\"a1\",\"name\":\"Bank A\"," + + "\"data\":{\"currency\":\"VES\"}}]," + + "\"pagination\":{\"current_page\":1,\"per_page\":50,\"total_pages\":1,\"total_count\":1}}")); + + AccountsPage page = client.accounts().list(new AccountsClient.ListParams().page(1).perPage(50)); + + assertEquals(1, page.total()); + assertEquals("a1", page.accounts().get(0).id()); + assertEquals("Bank A", page.accounts().get(0).name()); + assertEquals("VES", page.accounts().get(0).data().currency()); + + RecordedRequest rr = server.takeRequest(); + assertTrue(rr.getPath().contains("/v1/accounts"), rr.getPath()); + assertTrue(rr.getPath().contains("page=1"), rr.getPath()); + assertTrue(rr.getPath().contains("per_page=50"), rr.getPath()); + } + + @Test + void getReturnsTypedAccount() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200) + .setBody("{\"id\":\"a1\",\"name\":\"Bank A\",\"data\":{\"currency\":\"USD\"}," + + "\"bank\":{\"name\":\"Banco Test\"}}")); + + Account acct = client.accounts().get("a1"); + assertEquals("a1", acct.id()); + assertEquals("Banco Test", acct.bank().name()); + + RecordedRequest rr = server.takeRequest(); + assertEquals("/api/v1/accounts/a1", rr.getPath()); + } + + @Test + void notFoundMapsToTypedException() { + server.enqueue(new MockResponse().setResponseCode(404) + .setBody("{\"error\":\"missing\",\"error_code\":\"ACCOUNT_NOT_FOUND\"}")); + + AccountNotFoundException ex = assertThrows(AccountNotFoundException.class, + () -> client.accounts().get("a-missing")); + assertEquals("ACCOUNT_NOT_FOUND", ex.errorCode()); + assertEquals(404, ex.httpStatus()); + } + + @Test + void unauthorizedOnList() { + server.enqueue(new MockResponse().setResponseCode(401) + .setBody("{\"error\":\"bad key\",\"error_code\":\"UNAUTHORIZED\"}")); + + UnauthorizedException ex = assertThrows(UnauthorizedException.class, + () -> client.accounts().list()); + assertEquals(401, ex.httpStatus()); + } + + @Test + void listIdLookupSendsBearer() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200) + .setBody("{\"total\":0,\"accounts\":[],\"pagination\":{}}")); + client.accounts().list(); + RecordedRequest rr = server.takeRequest(); + assertNotNull(rr.getHeader("Authorization")); + assertTrue(rr.getHeader("Authorization").startsWith("Bearer ")); + } +} diff --git a/packages/java/src/test/java/com/tesote/sdk/v1/V1StatusClientTest.java b/packages/java/src/test/java/com/tesote/sdk/v1/V1StatusClientTest.java new file mode 100644 index 0000000..b877785 --- /dev/null +++ b/packages/java/src/test/java/com/tesote/sdk/v1/V1StatusClientTest.java @@ -0,0 +1,57 @@ +package com.tesote.sdk.v1; + +import com.tesote.sdk.models.Status; +import com.tesote.sdk.models.Whoami; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class V1StatusClientTest { + private MockWebServer server; + private V1Client client; + + @BeforeEach + void setUp() throws IOException { + server = new MockWebServer(); + server.start(); + client = V1Client.builder() + .apiKey("sk_test_abcd1234") + .baseUrl(server.url("/api").toString()) + .build(); + } + + @AfterEach + void tearDown() throws IOException { server.shutdown(); } + + @Test + void statusReturnsTyped() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200) + .setBody("{\"status\":\"ok\",\"authenticated\":false}")); + + Status status = client.status().status(); + assertEquals("ok", status.status()); + assertFalse(status.authenticated()); + + RecordedRequest rr = server.takeRequest(); + assertTrue(rr.getPath().endsWith("/status")); + } + + @Test + void whoamiReturnsClient() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200) + .setBody("{\"client\":{\"id\":\"cli_1\",\"name\":\"Acme\",\"type\":\"workspace\"}}")); + + Whoami w = client.status().whoami(); + assertEquals("cli_1", w.client().id()); + assertEquals("workspace", w.client().type()); + } +} diff --git a/packages/java/src/test/java/com/tesote/sdk/v1/V1TransactionsClientTest.java b/packages/java/src/test/java/com/tesote/sdk/v1/V1TransactionsClientTest.java new file mode 100644 index 0000000..a03a633 --- /dev/null +++ b/packages/java/src/test/java/com/tesote/sdk/v1/V1TransactionsClientTest.java @@ -0,0 +1,100 @@ +package com.tesote.sdk.v1; + +import com.tesote.sdk.errors.AccountNotFoundException; +import com.tesote.sdk.errors.InvalidDateRangeException; +import com.tesote.sdk.errors.TransactionNotFoundException; +import com.tesote.sdk.models.Transaction; +import com.tesote.sdk.models.TransactionsPage; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class V1TransactionsClientTest { + private MockWebServer server; + private V1Client client; + + @BeforeEach + void setUp() throws IOException { + server = new MockWebServer(); + server.start(); + client = V1Client.builder() + .apiKey("sk_test_abcd1234") + .baseUrl(server.url("/api").toString()) + .build(); + } + + @AfterEach + void tearDown() throws IOException { server.shutdown(); } + + @Test + void listForAccountWithCursor() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200) + .setBody("{\"total\":1,\"transactions\":[{\"id\":\"t1\",\"status\":\"posted\"," + + "\"data\":{\"amount_cents\":1500}}]," + + "\"pagination\":{\"has_more\":true,\"per_page\":50,\"after_id\":\"t1\",\"before_id\":\"t1\"}}")); + + TransactionsPage page = client.transactions().listForAccount("acct_1", + new TransactionsClient.ListParams() + .startDate("2026-01-01") + .endDate("2026-01-31") + .perPage(50)); + + assertEquals(1, page.transactions().size()); + assertEquals("t1", page.transactions().get(0).id()); + assertTrue(page.pagination().hasMore()); + assertEquals(1500L, page.transactions().get(0).data().amountCents()); + + RecordedRequest rr = server.takeRequest(); + assertTrue(rr.getPath().contains("/v1/accounts/acct_1/transactions")); + assertTrue(rr.getPath().contains("start_date=2026-01-01")); + assertTrue(rr.getPath().contains("per_page=50")); + } + + @Test + void getTransactionByIdMaps() { + server.enqueue(new MockResponse().setResponseCode(200) + .setBody("{\"id\":\"t99\",\"status\":\"posted\"," + + "\"data\":{\"amount_cents\":1,\"currency\":\"VES\",\"description\":\"x\"}}")); + + Transaction t = client.transactions().get("t99"); + assertEquals("t99", t.id()); + assertEquals("posted", t.status()); + assertEquals("VES", t.data().currency()); + } + + @Test + void invalidDateRangeMapsTo422() { + server.enqueue(new MockResponse().setResponseCode(422) + .setBody("{\"error\":\"bad range\",\"error_code\":\"INVALID_DATE_RANGE\"}")); + + InvalidDateRangeException ex = assertThrows(InvalidDateRangeException.class, + () -> client.transactions().listForAccount("acct_1", + new TransactionsClient.ListParams().startDate("2030-01-01").endDate("2020-01-01"))); + assertEquals("INVALID_DATE_RANGE", ex.errorCode()); + } + + @Test + void accountNotFoundOnList() { + server.enqueue(new MockResponse().setResponseCode(404) + .setBody("{\"error\":\"x\",\"error_code\":\"ACCOUNT_NOT_FOUND\"}")); + assertThrows(AccountNotFoundException.class, + () -> client.transactions().listForAccount("missing")); + } + + @Test + void transactionNotFoundOnGet() { + server.enqueue(new MockResponse().setResponseCode(404) + .setBody("{\"error\":\"x\",\"error_code\":\"TRANSACTION_NOT_FOUND\"}")); + assertThrows(TransactionNotFoundException.class, + () -> client.transactions().get("missing")); + } +} diff --git a/packages/java/src/test/java/com/tesote/sdk/v2/V2AccountsClientTest.java b/packages/java/src/test/java/com/tesote/sdk/v2/V2AccountsClientTest.java new file mode 100644 index 0000000..d756c71 --- /dev/null +++ b/packages/java/src/test/java/com/tesote/sdk/v2/V2AccountsClientTest.java @@ -0,0 +1,114 @@ +package com.tesote.sdk.v2; + +import com.tesote.sdk.errors.SyncInProgressException; +import com.tesote.sdk.errors.SyncRateLimitExceededException; +import com.tesote.sdk.models.Account; +import com.tesote.sdk.models.AccountSyncResponse; +import com.tesote.sdk.models.AccountsPage; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class V2AccountsClientTest { + private MockWebServer server; + private V2Client client; + + @BeforeEach + void setUp() throws IOException { + server = new MockWebServer(); + server.start(); + client = V2Client.builder() + .apiKey("sk_test_abcd1234") + .baseUrl(server.url("/api").toString()) + .retryPolicy(new com.tesote.sdk.Transport.RetryPolicy( + 1, Duration.ofMillis(1), Duration.ofMillis(2), false)) + .build(); + } + + @AfterEach + void tearDown() throws IOException { server.shutdown(); } + + @Test + void listSendsToV2Path() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200) + .setBody("{\"total\":0,\"accounts\":[],\"pagination\":{}}")); + + AccountsPage page = client.accounts().list(); + assertNotNull(page); + RecordedRequest rr = server.takeRequest(); + assertTrue(rr.getPath().startsWith("/api/v2/accounts"), rr.getPath()); + } + + @Test + void getDeserializesV2Account() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200) + .setBody("{\"id\":\"a1\",\"name\":\"v2 acct\"," + + "\"data\":{\"currency\":\"VES\",\"balance_cents\":\"1000\"}}")); + + Account a = client.accounts().get("a1"); + assertEquals("a1", a.id()); + assertEquals("1000", a.data().balanceCents()); + } + + @Test + void syncReturns202Body() throws Exception { + server.enqueue(new MockResponse().setResponseCode(202) + .setBody("{\"message\":\"Sync started\",\"sync_session_id\":\"ss_1\"," + + "\"status\":\"pending\",\"started_at\":\"2026-04-28T19:21:00Z\"}")); + + AccountSyncResponse resp = client.accounts().sync("a1"); + assertEquals("ss_1", resp.syncSessionId()); + assertEquals("pending", resp.status()); + + RecordedRequest rr = server.takeRequest(); + assertEquals("POST", rr.getMethod()); + assertEquals("/api/v2/accounts/a1/sync", rr.getPath()); + assertEquals("application/json", rr.getHeader("Content-Type")); + assertNotNull(rr.getHeader("Idempotency-Key")); + } + + @Test + void syncIdempotencyKeyHonored() throws Exception { + server.enqueue(new MockResponse().setResponseCode(202) + .setBody("{\"sync_session_id\":\"ss_1\",\"status\":\"pending\"}")); + + client.accounts().sync("a1", "my-key"); + RecordedRequest rr = server.takeRequest(); + assertEquals("my-key", rr.getHeader("Idempotency-Key")); + } + + @Test + void syncInProgressMaps() { + server.enqueue(new MockResponse().setResponseCode(409) + .setBody("{\"error\":\"sync running\",\"error_code\":\"SYNC_IN_PROGRESS\"," + + "\"current_session_id\":\"ss_running\"}")); + + SyncInProgressException ex = assertThrows(SyncInProgressException.class, + () -> client.accounts().sync("a1")); + assertEquals(409, ex.httpStatus()); + assertTrue(ex.responseBody().contains("ss_running")); + } + + @Test + void syncRateLimitMapsToTypedException() { + server.enqueue(new MockResponse().setResponseCode(429) + .setHeader("Retry-After", "300") + .setBody("{\"error\":\"too soon\",\"error_code\":\"SYNC_RATE_LIMIT_EXCEEDED\"," + + "\"retry_after\":300}")); + + SyncRateLimitExceededException ex = assertThrows(SyncRateLimitExceededException.class, + () -> client.accounts().sync("a1")); + assertEquals(300, ex.retryAfter()); + } +} diff --git a/packages/java/src/test/java/com/tesote/sdk/v2/V2BatchesClientTest.java b/packages/java/src/test/java/com/tesote/sdk/v2/V2BatchesClientTest.java new file mode 100644 index 0000000..96f4c4f --- /dev/null +++ b/packages/java/src/test/java/com/tesote/sdk/v2/V2BatchesClientTest.java @@ -0,0 +1,138 @@ +package com.tesote.sdk.v2; + +import com.tesote.sdk.Transport; +import com.tesote.sdk.errors.BatchNotFoundException; +import com.tesote.sdk.errors.BatchValidationException; +import com.tesote.sdk.errors.InvalidOrderStateException; +import com.tesote.sdk.models.BatchActionResponse; +import com.tesote.sdk.models.BatchCreateResponse; +import com.tesote.sdk.models.BatchSummary; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class V2BatchesClientTest { + private MockWebServer server; + private V2Client client; + + @BeforeEach + void setUp() throws IOException { + server = new MockWebServer(); + server.start(); + client = V2Client.builder() + .apiKey("sk_test_abcd1234") + .baseUrl(server.url("/api").toString()) + .retryPolicy(new Transport.RetryPolicy( + 1, Duration.ofMillis(1), Duration.ofMillis(2), false)) + .build(); + } + + @AfterEach + void tearDown() throws IOException { server.shutdown(); } + + @Test + void createReturnsBatchIdAndOrders() throws Exception { + server.enqueue(new MockResponse().setResponseCode(201) + .setBody("{\"batch_id\":\"b1\",\"orders\":[{\"id\":\"o1\",\"status\":\"draft\"}],\"errors\":[]}")); + + BatchCreateResponse resp = client.batches().create("a1", + new BatchesClient.CreateRequest() + .add(new TransactionOrdersClient.CreateRequest() + .amount("10").currency("VES").description("x"))); + assertEquals("b1", resp.batchId()); + assertEquals(1, resp.orders().size()); + + RecordedRequest rr = server.takeRequest(); + assertEquals("POST", rr.getMethod()); + assertEquals("/api/v2/accounts/a1/batches", rr.getPath()); + String body = rr.getBody().readUtf8(); + assertTrue(body.contains("\"orders\"")); + } + + @Test + void showReturnsSummary() { + server.enqueue(new MockResponse().setResponseCode(200) + .setBody("{\"batch_id\":\"b1\",\"total_orders\":2,\"total_amount_cents\":1000," + + "\"amount_currency\":\"VES\",\"statuses\":{\"draft\":2}," + + "\"batch_status\":\"draft\",\"orders\":[]}")); + BatchSummary s = client.batches().show("a1", "b1"); + assertEquals("b1", s.batchId()); + assertEquals(2, s.totalOrders()); + assertEquals(2, s.statuses().get("draft")); + } + + @Test + void approveSendsPost() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200) + .setBody("{\"approved\":3,\"failed\":0}")); + BatchActionResponse r = client.batches().approve("a1", "b1"); + assertEquals(3, r.approved()); + + RecordedRequest rr = server.takeRequest(); + assertEquals("POST", rr.getMethod()); + assertEquals("/api/v2/accounts/a1/batches/b1/approve", rr.getPath()); + assertEquals("application/json", rr.getHeader("Content-Type")); + assertNotNull(rr.getHeader("Idempotency-Key")); + } + + @Test + void submitWithToken() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200) + .setBody("{\"enqueued\":3,\"failed\":0}")); + + BatchActionResponse r = client.batches().submit("a1", "b1", "tok_42"); + assertEquals(3, r.enqueued()); + + RecordedRequest rr = server.takeRequest(); + String body = rr.getBody().readUtf8(); + assertTrue(body.contains("tok_42")); + } + + @Test + void cancelSendsPost() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200) + .setBody("{\"cancelled\":2,\"skipped\":1,\"errors\":[]}")); + + BatchActionResponse r = client.batches().cancel("a1", "b1"); + assertEquals(2, r.cancelled()); + assertEquals(1, r.skipped()); + } + + @Test + void batchValidationMaps() { + server.enqueue(new MockResponse().setResponseCode(400) + .setBody("{\"error\":\"bad\",\"error_code\":\"BATCH_VALIDATION_ERROR\"}")); + + assertThrows(BatchValidationException.class, + () -> client.batches().create("a1", new BatchesClient.CreateRequest())); + } + + @Test + void invalidOrderStateOnApprove() { + server.enqueue(new MockResponse().setResponseCode(409) + .setBody("{\"error\":\"x\",\"error_code\":\"INVALID_ORDER_STATE\"}")); + + assertThrows(InvalidOrderStateException.class, + () -> client.batches().approve("a1", "b1")); + } + + @Test + void notFoundOnShow() { + server.enqueue(new MockResponse().setResponseCode(404) + .setBody("{\"error\":\"x\",\"error_code\":\"BATCH_NOT_FOUND\"}")); + + assertThrows(BatchNotFoundException.class, + () -> client.batches().show("a1", "missing")); + } +} diff --git a/packages/java/src/test/java/com/tesote/sdk/v2/V2PaymentMethodsClientTest.java b/packages/java/src/test/java/com/tesote/sdk/v2/V2PaymentMethodsClientTest.java new file mode 100644 index 0000000..42b7be8 --- /dev/null +++ b/packages/java/src/test/java/com/tesote/sdk/v2/V2PaymentMethodsClientTest.java @@ -0,0 +1,138 @@ +package com.tesote.sdk.v2; + +import com.tesote.sdk.Transport; +import com.tesote.sdk.errors.PaymentMethodNotFoundException; +import com.tesote.sdk.errors.ValidationException; +import com.tesote.sdk.models.OffsetPage; +import com.tesote.sdk.models.PaymentMethod; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.time.Duration; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class V2PaymentMethodsClientTest { + private MockWebServer server; + private V2Client client; + + @BeforeEach + void setUp() throws IOException { + server = new MockWebServer(); + server.start(); + client = V2Client.builder() + .apiKey("sk_test_abcd1234") + .baseUrl(server.url("/api").toString()) + .retryPolicy(new Transport.RetryPolicy( + 1, Duration.ofMillis(1), Duration.ofMillis(2), false)) + .build(); + } + + @AfterEach + void tearDown() throws IOException { server.shutdown(); } + + @Test + void listSendsFilters() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200) + .setBody("{\"items\":[{\"id\":\"pm1\",\"method_type\":\"bank_account\"," + + "\"currency\":\"VES\"}],\"limit\":50,\"offset\":0,\"has_more\":false}")); + + OffsetPage page = client.paymentMethods().list( + new PaymentMethodsClient.ListParams() + .limit(50).offset(0) + .methodType("bank_account").verified(true)); + + assertEquals(1, page.items().size()); + assertEquals("pm1", page.items().get(0).id()); + + RecordedRequest rr = server.takeRequest(); + assertTrue(rr.getPath().contains("method_type=bank_account")); + assertTrue(rr.getPath().contains("verified=true")); + } + + @Test + void getReturnsTyped() { + server.enqueue(new MockResponse().setResponseCode(200) + .setBody("{\"id\":\"pm1\",\"method_type\":\"bank_account\",\"currency\":\"VES\"," + + "\"verified\":true}")); + PaymentMethod pm = client.paymentMethods().get("pm1"); + assertEquals("pm1", pm.id()); + assertTrue(pm.verified()); + } + + @Test + void createSendsEnvelope() throws Exception { + server.enqueue(new MockResponse().setResponseCode(201) + .setBody("{\"id\":\"pm1\",\"method_type\":\"bank_account\",\"currency\":\"VES\"}")); + + PaymentMethod pm = client.paymentMethods().create(new PaymentMethodsClient.CreateRequest() + .methodType("bank_account") + .currency("VES") + .label("Primary") + .counterparty(new PaymentMethodsClient.Counterparty().name("Alice")) + .details(Map.of("bank_code", "0102", "account_number", "1234"))); + + assertEquals("pm1", pm.id()); + + RecordedRequest rr = server.takeRequest(); + assertEquals("POST", rr.getMethod()); + String body = rr.getBody().readUtf8(); + assertTrue(body.contains("\"payment_method\"")); + assertTrue(body.contains("\"method_type\":\"bank_account\"")); + assertTrue(body.contains("\"name\":\"Alice\"")); + } + + @Test + void updateSendsPatch() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200) + .setBody("{\"id\":\"pm1\",\"method_type\":\"bank_account\",\"label\":\"Renamed\"}")); + + PaymentMethod pm = client.paymentMethods().update("pm1", + new PaymentMethodsClient.UpdateRequest().label("Renamed")); + assertEquals("Renamed", pm.label()); + + RecordedRequest rr = server.takeRequest(); + assertEquals("PATCH", rr.getMethod()); + assertEquals("/api/v2/payment_methods/pm1", rr.getPath()); + assertEquals("application/json", rr.getHeader("Content-Type")); + assertNotNull(rr.getHeader("Idempotency-Key")); + } + + @Test + void deleteSendsDelete() throws Exception { + server.enqueue(new MockResponse().setResponseCode(204)); + + client.paymentMethods().delete("pm1"); + + RecordedRequest rr = server.takeRequest(); + assertEquals("DELETE", rr.getMethod()); + assertEquals("/api/v2/payment_methods/pm1", rr.getPath()); + } + + @Test + void notFoundOnGet() { + server.enqueue(new MockResponse().setResponseCode(404) + .setBody("{\"error\":\"missing\",\"error_code\":\"PAYMENT_METHOD_NOT_FOUND\"}")); + + assertThrows(PaymentMethodNotFoundException.class, + () -> client.paymentMethods().get("missing")); + } + + @Test + void validationOnCreate() { + server.enqueue(new MockResponse().setResponseCode(400) + .setBody("{\"error\":\"bad\",\"error_code\":\"VALIDATION_ERROR\"}")); + + assertThrows(ValidationException.class, + () -> client.paymentMethods().create(new PaymentMethodsClient.CreateRequest())); + } +} diff --git a/packages/java/src/test/java/com/tesote/sdk/v2/V2StatusClientTest.java b/packages/java/src/test/java/com/tesote/sdk/v2/V2StatusClientTest.java new file mode 100644 index 0000000..f8d223e --- /dev/null +++ b/packages/java/src/test/java/com/tesote/sdk/v2/V2StatusClientTest.java @@ -0,0 +1,55 @@ +package com.tesote.sdk.v2; + +import com.tesote.sdk.models.Status; +import com.tesote.sdk.models.Whoami; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class V2StatusClientTest { + private MockWebServer server; + private V2Client client; + + @BeforeEach + void setUp() throws IOException { + server = new MockWebServer(); + server.start(); + client = V2Client.builder() + .apiKey("sk_test_abcd1234") + .baseUrl(server.url("/api").toString()) + .build(); + } + + @AfterEach + void tearDown() throws IOException { server.shutdown(); } + + @Test + void v2StatusUsesV2Path() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200) + .setBody("{\"status\":\"ok\",\"authenticated\":false}")); + + Status s = client.status().status(); + assertEquals("ok", s.status()); + RecordedRequest rr = server.takeRequest(); + assertTrue(rr.getPath().endsWith("/v2/status"), rr.getPath()); + } + + @Test + void v2WhoamiUsesV2Path() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200) + .setBody("{\"client\":{\"id\":\"c1\",\"name\":\"x\",\"type\":\"workspace\"}}")); + + Whoami w = client.status().whoami(); + assertEquals("c1", w.client().id()); + RecordedRequest rr = server.takeRequest(); + assertTrue(rr.getPath().endsWith("/v2/whoami"), rr.getPath()); + } +} diff --git a/packages/java/src/test/java/com/tesote/sdk/v2/V2SyncSessionsClientTest.java b/packages/java/src/test/java/com/tesote/sdk/v2/V2SyncSessionsClientTest.java new file mode 100644 index 0000000..6f59778 --- /dev/null +++ b/packages/java/src/test/java/com/tesote/sdk/v2/V2SyncSessionsClientTest.java @@ -0,0 +1,72 @@ +package com.tesote.sdk.v2; + +import com.tesote.sdk.errors.SyncSessionNotFoundException; +import com.tesote.sdk.models.SyncSession; +import com.tesote.sdk.models.SyncSessionsPage; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class V2SyncSessionsClientTest { + private MockWebServer server; + private V2Client client; + + @BeforeEach + void setUp() throws IOException { + server = new MockWebServer(); + server.start(); + client = V2Client.builder() + .apiKey("sk_test_abcd1234") + .baseUrl(server.url("/api").toString()) + .build(); + } + + @AfterEach + void tearDown() throws IOException { server.shutdown(); } + + @Test + void listAndPagination() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200) + .setBody("{\"sync_sessions\":[{\"id\":\"ss1\",\"status\":\"completed\"}]," + + "\"limit\":50,\"offset\":0,\"has_more\":false}")); + + SyncSessionsPage page = client.syncSessions().list("a1", + new SyncSessionsClient.ListParams().limit(50).offset(0).status("completed")); + + assertEquals(1, page.syncSessions().size()); + assertEquals("ss1", page.syncSessions().get(0).id()); + + RecordedRequest rr = server.takeRequest(); + assertTrue(rr.getPath().contains("/v2/accounts/a1/sync_sessions")); + assertTrue(rr.getPath().contains("status=completed")); + } + + @Test + void getReturnsSession() { + server.enqueue(new MockResponse().setResponseCode(200) + .setBody("{\"id\":\"ss1\",\"status\":\"failed\"," + + "\"error\":{\"type\":\"BankError\",\"message\":\"down\"}}")); + + SyncSession s = client.syncSessions().get("a1", "ss1"); + assertEquals("ss1", s.id()); + assertEquals("BankError", s.error().type()); + } + + @Test + void notFoundMaps() { + server.enqueue(new MockResponse().setResponseCode(404) + .setBody("{\"error\":\"missing\",\"error_code\":\"SYNC_SESSION_NOT_FOUND\"}")); + + assertThrows(SyncSessionNotFoundException.class, + () -> client.syncSessions().get("a1", "missing")); + } +} diff --git a/packages/java/src/test/java/com/tesote/sdk/v2/V2TransactionOrdersClientTest.java b/packages/java/src/test/java/com/tesote/sdk/v2/V2TransactionOrdersClientTest.java new file mode 100644 index 0000000..1d31b33 --- /dev/null +++ b/packages/java/src/test/java/com/tesote/sdk/v2/V2TransactionOrdersClientTest.java @@ -0,0 +1,161 @@ +package com.tesote.sdk.v2; + +import com.tesote.sdk.Transport; +import com.tesote.sdk.errors.InvalidOrderStateException; +import com.tesote.sdk.errors.TransactionOrderNotFoundException; +import com.tesote.sdk.errors.ValidationException; +import com.tesote.sdk.models.OffsetPage; +import com.tesote.sdk.models.TransactionOrder; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class V2TransactionOrdersClientTest { + private MockWebServer server; + private V2Client client; + + @BeforeEach + void setUp() throws IOException { + server = new MockWebServer(); + server.start(); + client = V2Client.builder() + .apiKey("sk_test_abcd1234") + .baseUrl(server.url("/api").toString()) + .retryPolicy(new Transport.RetryPolicy( + 1, Duration.ofMillis(1), Duration.ofMillis(2), false)) + .build(); + } + + @AfterEach + void tearDown() throws IOException { server.shutdown(); } + + @Test + void listOffsetPagination() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200) + .setBody("{\"items\":[{\"id\":\"o1\",\"status\":\"draft\"}]," + + "\"limit\":50,\"offset\":0,\"has_more\":false}")); + + OffsetPage page = client.transactionOrders().list("a1", + new TransactionOrdersClient.ListParams().limit(50).offset(0).status("draft")); + + assertEquals(1, page.items().size()); + assertEquals("o1", page.items().get(0).id()); + + RecordedRequest rr = server.takeRequest(); + assertTrue(rr.getPath().contains("/v2/accounts/a1/transaction_orders")); + assertTrue(rr.getPath().contains("status=draft")); + } + + @Test + void getReturnsOrder() { + server.enqueue(new MockResponse().setResponseCode(200) + .setBody("{\"id\":\"o1\",\"status\":\"draft\",\"amount\":100,\"currency\":\"VES\"}")); + TransactionOrder o = client.transactionOrders().get("a1", "o1"); + assertEquals("o1", o.id()); + } + + @Test + void createSendsEnvelope() throws Exception { + server.enqueue(new MockResponse().setResponseCode(201) + .setBody("{\"id\":\"o1\",\"status\":\"draft\",\"amount\":100,\"currency\":\"VES\"}")); + + TransactionOrder o = client.transactionOrders().create("a1", + new TransactionOrdersClient.CreateRequest() + .amount("100") + .currency("VES") + .description("rent") + .idempotencyKey("k_1") + .beneficiary(new TransactionOrdersClient.Beneficiary() + .name("Alice").bankCode("0102"))); + assertEquals("o1", o.id()); + + RecordedRequest rr = server.takeRequest(); + assertEquals("POST", rr.getMethod()); + String body = rr.getBody().readUtf8(); + assertTrue(body.contains("\"transaction_order\"")); + assertTrue(body.contains("\"amount\":\"100\"")); + assertTrue(body.contains("\"currency\":\"VES\"")); + assertTrue(body.contains("\"bank_code\":\"0102\"")); + assertEquals("k_1", rr.getHeader("Idempotency-Key")); + } + + @Test + void submitWithToken() throws Exception { + server.enqueue(new MockResponse().setResponseCode(202) + .setBody("{\"id\":\"o1\",\"status\":\"processing\"}")); + + TransactionOrder o = client.transactionOrders().submit("a1", "o1", "123456"); + assertEquals("processing", o.status()); + + RecordedRequest rr = server.takeRequest(); + assertEquals("/api/v2/accounts/a1/transaction_orders/o1/submit", rr.getPath()); + String body = rr.getBody().readUtf8(); + assertTrue(body.contains("\"token\":\"123456\"")); + } + + @Test + void submitWithoutTokenSendsEmptyJsonBody() throws Exception { + server.enqueue(new MockResponse().setResponseCode(202) + .setBody("{\"id\":\"o1\",\"status\":\"processing\"}")); + + client.transactionOrders().submit("a1", "o1"); + + RecordedRequest rr = server.takeRequest(); + assertEquals("application/json", rr.getHeader("Content-Type")); + } + + @Test + void cancelSendsPostWithEmptyBody() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200) + .setBody("{\"id\":\"o1\",\"status\":\"cancelled\"}")); + + TransactionOrder o = client.transactionOrders().cancel("a1", "o1"); + assertEquals("cancelled", o.status()); + + RecordedRequest rr = server.takeRequest(); + assertEquals("POST", rr.getMethod()); + assertEquals("/api/v2/accounts/a1/transaction_orders/o1/cancel", rr.getPath()); + // why: spec says POST/PUT/PATCH must always carry Content-Type. + assertEquals("application/json", rr.getHeader("Content-Type")); + assertNotNull(rr.getHeader("Idempotency-Key")); + } + + @Test + void invalidOrderStateOnSubmit() { + server.enqueue(new MockResponse().setResponseCode(409) + .setBody("{\"error\":\"bad state\",\"error_code\":\"INVALID_ORDER_STATE\"}")); + + assertThrows(InvalidOrderStateException.class, + () -> client.transactionOrders().submit("a1", "o1")); + } + + @Test + void notFoundOnGet() { + server.enqueue(new MockResponse().setResponseCode(404) + .setBody("{\"error\":\"x\",\"error_code\":\"TRANSACTION_ORDER_NOT_FOUND\"}")); + + assertThrows(TransactionOrderNotFoundException.class, + () -> client.transactionOrders().get("a1", "missing")); + } + + @Test + void validationOnCreate() { + server.enqueue(new MockResponse().setResponseCode(400) + .setBody("{\"error\":\"bad amount\",\"error_code\":\"VALIDATION_ERROR\"}")); + + assertThrows(ValidationException.class, + () -> client.transactionOrders().create("a1", + new TransactionOrdersClient.CreateRequest().amount("-1"))); + } +} diff --git a/packages/java/src/test/java/com/tesote/sdk/v2/V2TransactionsClientTest.java b/packages/java/src/test/java/com/tesote/sdk/v2/V2TransactionsClientTest.java new file mode 100644 index 0000000..69f5c77 --- /dev/null +++ b/packages/java/src/test/java/com/tesote/sdk/v2/V2TransactionsClientTest.java @@ -0,0 +1,198 @@ +package com.tesote.sdk.v2; + +import com.tesote.sdk.Transport; +import com.tesote.sdk.errors.InvalidCountException; +import com.tesote.sdk.errors.UnprocessableContentException; +import com.tesote.sdk.errors.HistorySyncForbiddenException; +import com.tesote.sdk.models.BulkResponse; +import com.tesote.sdk.models.SyncTransactionsResponse; +import com.tesote.sdk.models.Transaction; +import com.tesote.sdk.models.TransactionsExport; +import com.tesote.sdk.models.TransactionsPage; +import com.tesote.sdk.models.TransactionsSearchResponse; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.time.Duration; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class V2TransactionsClientTest { + private MockWebServer server; + private V2Client client; + + @BeforeEach + void setUp() throws IOException { + server = new MockWebServer(); + server.start(); + client = V2Client.builder() + .apiKey("sk_test_abcd1234") + .baseUrl(server.url("/api").toString()) + .retryPolicy(new Transport.RetryPolicy( + 1, Duration.ofMillis(1), Duration.ofMillis(2), false)) + .build(); + } + + @AfterEach + void tearDown() throws IOException { server.shutdown(); } + + @Test + void listForAccountSendsAllFilters() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200) + .setBody("{\"total\":0,\"transactions\":[],\"pagination\":{\"has_more\":false}}")); + + TransactionsClient.ListParams params = new TransactionsClient.ListParams() + .startDate("2026-01-01").endDate("2026-01-31") + .amountMin("10").amountMax("100") + .status("posted") + .categoryId("c1"); + + TransactionsPage page = client.transactions().listForAccount("acct_1", params); + assertNotNull(page); + + RecordedRequest rr = server.takeRequest(); + assertTrue(rr.getPath().contains("/v2/accounts/acct_1/transactions")); + assertTrue(rr.getPath().contains("amount_min=10")); + assertTrue(rr.getPath().contains("amount_max=100")); + assertTrue(rr.getPath().contains("status=posted")); + assertTrue(rr.getPath().contains("category_id=c1")); + } + + @Test + void getReturnsTypedTransaction() { + server.enqueue(new MockResponse().setResponseCode(200) + .setBody("{\"id\":\"t1\",\"status\":\"posted\"," + + "\"data\":{\"amount_cents\":2500,\"currency\":\"VES\"}}")); + Transaction t = client.transactions().get("t1"); + assertEquals("t1", t.id()); + assertEquals(2500L, t.data().amountCents()); + } + + @Test + void syncSendsNestedOptionsAndDeserializes() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200) + .setBody("{\"added\":[{\"transaction_id\":\"t1\",\"account_id\":\"a1\"," + + "\"amount\":1000.0,\"name\":\"x\"}]," + + "\"modified\":[],\"removed\":[],\"next_cursor\":\"c2\",\"has_more\":false}")); + + SyncTransactionsResponse resp = client.transactions().sync("a1", + new TransactionsClient.SyncRequest() + .count(100).cursor("now").includeRunningBalance(true)); + + assertEquals(1, resp.added().size()); + assertEquals("t1", resp.added().get(0).transactionId()); + assertEquals("c2", resp.nextCursor()); + + RecordedRequest rr = server.takeRequest(); + assertEquals("POST", rr.getMethod()); + assertEquals("application/json", rr.getHeader("Content-Type")); + String body = rr.getBody().readUtf8(); + assertTrue(body.contains("\"count\":100"), body); + assertTrue(body.contains("\"cursor\":\"now\""), body); + assertTrue(body.contains("\"include_running_balance\":true"), body); + } + + @Test + void syncLegacyHitsRootPath() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200) + .setBody("{\"added\":[],\"modified\":[],\"removed\":[],\"next_cursor\":null,\"has_more\":false}")); + + client.transactions().syncLegacy(new TransactionsClient.SyncRequest().count(50)); + + RecordedRequest rr = server.takeRequest(); + assertEquals("/api/v2/transactions/sync", rr.getPath()); + } + + @Test + void syncCountValidationMaps() { + server.enqueue(new MockResponse().setResponseCode(422) + .setBody("{\"error\":\"bad count\",\"error_code\":\"INVALID_COUNT\"}")); + + InvalidCountException ex = assertThrows(InvalidCountException.class, + () -> client.transactions().sync("a1", + new TransactionsClient.SyncRequest().count(0))); + assertEquals(422, ex.httpStatus()); + } + + @Test + void historicalSyncForbidden() { + server.enqueue(new MockResponse().setResponseCode(403) + .setBody("{\"error\":\"too far back\",\"error_code\":\"HISTORY_SYNC_FORBIDDEN\"}")); + assertThrows(HistorySyncForbiddenException.class, + () -> client.transactions().sync("a1", + new TransactionsClient.SyncRequest().cursor("ancient"))); + } + + @Test + void bulkSendsAccountIds() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200) + .setBody("{\"bulk_results\":[{\"account_id\":\"a1\"," + + "\"transactions\":[],\"pagination\":{\"has_more\":false}}]}")); + + BulkResponse resp = client.transactions().bulk( + new TransactionsClient.BulkRequest().accountIds(List.of("a1", "a2")).limit(50)); + assertEquals(1, resp.bulkResults().size()); + + RecordedRequest rr = server.takeRequest(); + String body = rr.getBody().readUtf8(); + assertTrue(body.contains("\"a1\"")); + assertTrue(body.contains("\"a2\"")); + assertTrue(body.contains("\"limit\":50")); + } + + @Test + void bulkUnprocessableMapsTypedException() { + server.enqueue(new MockResponse().setResponseCode(422) + .setBody("{\"error\":\"too many\",\"error_code\":\"UNPROCESSABLE_CONTENT\"}")); + + assertThrows(UnprocessableContentException.class, + () -> client.transactions().bulk(new TransactionsClient.BulkRequest())); + } + + @Test + void searchSendsQueryAndDeserializes() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200) + .setBody("{\"transactions\":[{\"id\":\"t1\",\"status\":\"posted\"," + + "\"data\":{\"amount_cents\":100}}],\"total\":1}")); + + TransactionsSearchResponse resp = client.transactions().search( + new TransactionsClient.SearchParams().q("groceries").limit(25)); + + assertEquals(1, resp.total()); + + RecordedRequest rr = server.takeRequest(); + assertTrue(rr.getPath().contains("q=groceries")); + assertTrue(rr.getPath().contains("limit=25")); + } + + @Test + void exportReturnsRawBytesAndFilename() throws Exception { + byte[] csv = "id,amount\nt1,100\n".getBytes(); + server.enqueue(new MockResponse().setResponseCode(200) + .setHeader("Content-Type", "text/csv") + .setHeader("Content-Disposition", "attachment; filename=\"transactions_a1_2026-04-28.csv\"") + .setBody(new okio.Buffer().write(csv))); + + TransactionsExport export = client.transactions().export("a1", + new TransactionsClient.ExportParams() + .format(TransactionsExport.Format.CSV)); + + assertArrayEquals(csv, export.body()); + assertEquals("text/csv", export.contentType()); + assertEquals("transactions_a1_2026-04-28.csv", export.filename()); + + RecordedRequest rr = server.takeRequest(); + assertTrue(rr.getPath().contains("/v2/accounts/a1/transactions/export")); + assertTrue(rr.getPath().contains("format=csv")); + } +} From 308ff65fd496a266b8458671786667b0d7477b61 Mon Sep 17 00:00:00 2001 From: sebi Date: Tue, 28 Apr 2026 19:43:46 -0500 Subject: [PATCH 04/10] php: implement full v1+v2 surface (0.2.0) 35 endpoints (6 v1 + 29 v2) wired against the Rails-controller-derived spec. Lands the Transport.php slim refactor (extracts BackoffStrategy, CacheKey, JsonCodec, RateLimitTracker, RequestBuilder, RequestSummarizer, RetryPolicy, TransportConfig, Uuid into src/Internal/) and wires every resource client on V1\Client / V2\Client (Accounts, Transactions, Status, plus v2 SyncSessions, TransactionOrders, Batches, PaymentMethods). Adds 37 readonly model classes with typed properties and 20 new typed exceptions per error_code. composer test/phpstan(L8)/cs-check clean, 84 phpunit tests / 302 assertions pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/php/CHANGELOG.md | 27 ++ packages/php/README.md | 10 +- packages/php/VERSION | 2 +- .../src/Errors/AccountNotFoundException.php | 10 + packages/php/src/Errors/ApiException.php | 20 + .../BankConnectionNotFoundException.php | 10 + .../src/Errors/BankSubmissionException.php | 10 + .../Errors/BankUnderMaintenanceException.php | 10 + .../php/src/Errors/BatchNotFoundException.php | 10 + .../src/Errors/BatchValidationException.php | 10 + .../php/src/Errors/InternalErrorException.php | 10 + .../php/src/Errors/InvalidCountException.php | 10 + .../php/src/Errors/InvalidCursorException.php | 10 + .../php/src/Errors/InvalidLimitException.php | 10 + .../src/Errors/InvalidOrderStateException.php | 10 + .../php/src/Errors/InvalidQueryException.php | 10 + .../src/Errors/MissingDateRangeException.php | 10 + .../Errors/PaymentMethodNotFoundException.php | 10 + .../src/Errors/SyncInProgressException.php | 10 + .../Errors/SyncRateLimitExceededException.php | 10 + .../Errors/SyncSessionNotFoundException.php | 10 + .../Errors/TransactionNotFoundException.php | 10 + .../TransactionOrderNotFoundException.php | 10 + .../php/src/Errors/ValidationException.php | 10 + packages/php/src/Internal/BackoffStrategy.php | 51 +++ packages/php/src/Internal/CacheKey.php | 19 + packages/php/src/Internal/JsonCodec.php | 43 ++ .../php/src/Internal/RateLimitTracker.php | 49 +++ packages/php/src/Internal/RequestBuilder.php | 89 ++++ .../php/src/Internal/RequestSummarizer.php | 52 +++ packages/php/src/Internal/RetryPolicy.php | 48 +++ packages/php/src/Internal/TransportConfig.php | 75 ++++ packages/php/src/Internal/Uuid.php | 30 ++ packages/php/src/Models/Account.php | 40 ++ packages/php/src/Models/AccountData.php | 42 ++ packages/php/src/Models/AccountList.php | 39 ++ packages/php/src/Models/Bank.php | 22 + packages/php/src/Models/BatchActionResult.php | 50 +++ packages/php/src/Models/BatchCreated.php | 46 +++ packages/php/src/Models/BatchSummary.php | 53 +++ packages/php/src/Models/BulkAccountResult.php | 39 ++ packages/php/src/Models/BulkResult.php | 32 ++ packages/php/src/Models/Counterparty.php | 32 ++ packages/php/src/Models/CursorPagination.php | 30 ++ packages/php/src/Models/Destination.php | 28 ++ packages/php/src/Models/ExportFile.php | 22 + packages/php/src/Models/LegalEntity.php | 26 ++ packages/php/src/Models/Money.php | 31 ++ packages/php/src/Models/PagePagination.php | 30 ++ packages/php/src/Models/PaymentMethod.php | 53 +++ packages/php/src/Models/PaymentMethodList.php | 40 ++ packages/php/src/Models/SourceAccount.php | 28 ++ packages/php/src/Models/StatusInfo.php | 26 ++ packages/php/src/Models/SyncRemoval.php | 26 ++ packages/php/src/Models/SyncResult.php | 56 +++ packages/php/src/Models/SyncSession.php | 58 +++ packages/php/src/Models/SyncSessionList.php | 40 ++ packages/php/src/Models/SyncStarted.php | 30 ++ packages/php/src/Models/SyncTransaction.php | 58 +++ packages/php/src/Models/TesoteAccountRef.php | 26 ++ .../php/src/Models/TesoteTransactionRef.php | 26 ++ packages/php/src/Models/Transaction.php | 50 +++ .../php/src/Models/TransactionCategory.php | 30 ++ packages/php/src/Models/TransactionData.php | 45 +++ packages/php/src/Models/TransactionList.php | 39 ++ packages/php/src/Models/TransactionOrder.php | 74 ++++ .../src/Models/TransactionOrderAttempt.php | 38 ++ .../php/src/Models/TransactionOrderList.php | 40 ++ .../src/Models/TransactionSearchResult.php | 36 ++ packages/php/src/Models/WhoAmI.php | 29 ++ packages/php/src/NotImplemented.php | 35 -- packages/php/src/Transport.php | 382 ++++++------------ packages/php/src/V1/Accounts.php | 59 ++- packages/php/src/V1/Client.php | 11 +- packages/php/src/V1/Status.php | 29 ++ packages/php/src/V1/Transactions.php | 30 ++ packages/php/src/V2/Accounts.php | 151 ++++++- packages/php/src/V2/Batches.php | 79 ++++ packages/php/src/V2/Client.php | 25 +- packages/php/src/V2/PaymentMethods.php | 87 ++++ packages/php/src/V2/Status.php | 29 ++ packages/php/src/V2/SyncSessions.php | 43 ++ packages/php/src/V2/TransactionOrders.php | 95 +++++ packages/php/src/V2/Transactions.php | 100 +++++ packages/php/tests/Support/TestCaseBase.php | 129 ++++++ packages/php/tests/V1/AccountsTest.php | 153 +++++++ packages/php/tests/V1/StatusTest.php | 38 ++ packages/php/tests/V1/TransactionsTest.php | 52 +++ packages/php/tests/V2/AccountsTest.php | 182 +++++++++ packages/php/tests/V2/BatchesTest.php | 98 +++++ packages/php/tests/V2/PaymentMethodsTest.php | 127 ++++++ packages/php/tests/V2/StatusTest.php | 42 ++ packages/php/tests/V2/SyncSessionsTest.php | 76 ++++ .../php/tests/V2/TransactionOrdersTest.php | 134 ++++++ packages/php/tests/V2/TransactionsTest.php | 109 +++++ 95 files changed, 4076 insertions(+), 344 deletions(-) create mode 100644 packages/php/src/Errors/AccountNotFoundException.php create mode 100644 packages/php/src/Errors/BankConnectionNotFoundException.php create mode 100644 packages/php/src/Errors/BankSubmissionException.php create mode 100644 packages/php/src/Errors/BankUnderMaintenanceException.php create mode 100644 packages/php/src/Errors/BatchNotFoundException.php create mode 100644 packages/php/src/Errors/BatchValidationException.php create mode 100644 packages/php/src/Errors/InternalErrorException.php create mode 100644 packages/php/src/Errors/InvalidCountException.php create mode 100644 packages/php/src/Errors/InvalidCursorException.php create mode 100644 packages/php/src/Errors/InvalidLimitException.php create mode 100644 packages/php/src/Errors/InvalidOrderStateException.php create mode 100644 packages/php/src/Errors/InvalidQueryException.php create mode 100644 packages/php/src/Errors/MissingDateRangeException.php create mode 100644 packages/php/src/Errors/PaymentMethodNotFoundException.php create mode 100644 packages/php/src/Errors/SyncInProgressException.php create mode 100644 packages/php/src/Errors/SyncRateLimitExceededException.php create mode 100644 packages/php/src/Errors/SyncSessionNotFoundException.php create mode 100644 packages/php/src/Errors/TransactionNotFoundException.php create mode 100644 packages/php/src/Errors/TransactionOrderNotFoundException.php create mode 100644 packages/php/src/Errors/ValidationException.php create mode 100644 packages/php/src/Internal/BackoffStrategy.php create mode 100644 packages/php/src/Internal/CacheKey.php create mode 100644 packages/php/src/Internal/JsonCodec.php create mode 100644 packages/php/src/Internal/RateLimitTracker.php create mode 100644 packages/php/src/Internal/RequestBuilder.php create mode 100644 packages/php/src/Internal/RequestSummarizer.php create mode 100644 packages/php/src/Internal/RetryPolicy.php create mode 100644 packages/php/src/Internal/TransportConfig.php create mode 100644 packages/php/src/Internal/Uuid.php create mode 100644 packages/php/src/Models/Account.php create mode 100644 packages/php/src/Models/AccountData.php create mode 100644 packages/php/src/Models/AccountList.php create mode 100644 packages/php/src/Models/Bank.php create mode 100644 packages/php/src/Models/BatchActionResult.php create mode 100644 packages/php/src/Models/BatchCreated.php create mode 100644 packages/php/src/Models/BatchSummary.php create mode 100644 packages/php/src/Models/BulkAccountResult.php create mode 100644 packages/php/src/Models/BulkResult.php create mode 100644 packages/php/src/Models/Counterparty.php create mode 100644 packages/php/src/Models/CursorPagination.php create mode 100644 packages/php/src/Models/Destination.php create mode 100644 packages/php/src/Models/ExportFile.php create mode 100644 packages/php/src/Models/LegalEntity.php create mode 100644 packages/php/src/Models/Money.php create mode 100644 packages/php/src/Models/PagePagination.php create mode 100644 packages/php/src/Models/PaymentMethod.php create mode 100644 packages/php/src/Models/PaymentMethodList.php create mode 100644 packages/php/src/Models/SourceAccount.php create mode 100644 packages/php/src/Models/StatusInfo.php create mode 100644 packages/php/src/Models/SyncRemoval.php create mode 100644 packages/php/src/Models/SyncResult.php create mode 100644 packages/php/src/Models/SyncSession.php create mode 100644 packages/php/src/Models/SyncSessionList.php create mode 100644 packages/php/src/Models/SyncStarted.php create mode 100644 packages/php/src/Models/SyncTransaction.php create mode 100644 packages/php/src/Models/TesoteAccountRef.php create mode 100644 packages/php/src/Models/TesoteTransactionRef.php create mode 100644 packages/php/src/Models/Transaction.php create mode 100644 packages/php/src/Models/TransactionCategory.php create mode 100644 packages/php/src/Models/TransactionData.php create mode 100644 packages/php/src/Models/TransactionList.php create mode 100644 packages/php/src/Models/TransactionOrder.php create mode 100644 packages/php/src/Models/TransactionOrderAttempt.php create mode 100644 packages/php/src/Models/TransactionOrderList.php create mode 100644 packages/php/src/Models/TransactionSearchResult.php create mode 100644 packages/php/src/Models/WhoAmI.php delete mode 100644 packages/php/src/NotImplemented.php create mode 100644 packages/php/src/V1/Status.php create mode 100644 packages/php/src/V1/Transactions.php create mode 100644 packages/php/src/V2/Batches.php create mode 100644 packages/php/src/V2/PaymentMethods.php create mode 100644 packages/php/src/V2/Status.php create mode 100644 packages/php/src/V2/SyncSessions.php create mode 100644 packages/php/src/V2/TransactionOrders.php create mode 100644 packages/php/src/V2/Transactions.php create mode 100644 packages/php/tests/Support/TestCaseBase.php create mode 100644 packages/php/tests/V1/AccountsTest.php create mode 100644 packages/php/tests/V1/StatusTest.php create mode 100644 packages/php/tests/V1/TransactionsTest.php create mode 100644 packages/php/tests/V2/AccountsTest.php create mode 100644 packages/php/tests/V2/BatchesTest.php create mode 100644 packages/php/tests/V2/PaymentMethodsTest.php create mode 100644 packages/php/tests/V2/StatusTest.php create mode 100644 packages/php/tests/V2/SyncSessionsTest.php create mode 100644 packages/php/tests/V2/TransactionOrdersTest.php create mode 100644 packages/php/tests/V2/TransactionsTest.php diff --git a/packages/php/CHANGELOG.md b/packages/php/CHANGELOG.md index 834a299..deb2a7f 100644 --- a/packages/php/CHANGELOG.md +++ b/packages/php/CHANGELOG.md @@ -4,6 +4,33 @@ All notable changes to `tesote/sdk` are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to semver per [docs/architecture/release.md](../../docs/architecture/release.md). +## 0.2.0 - 2026-04-28 + +### Added +- Full v1 + v2 resource surface (35 endpoints): `V1\Status`, `V1\Accounts` + (list/get/listTransactions), `V1\Transactions` (get); `V2\Status`, + `V2\Accounts` (list/get/sync/listTransactions/syncTransactions/ + exportTransactions), `V2\Transactions` (get/sync/bulk/search), + `V2\SyncSessions` (listForAccount/get), `V2\TransactionOrders` + (listForAccount/get/create/submit/cancel), `V2\Batches` + (create/get/approve/submit/cancel), `V2\PaymentMethods` + (list/get/create/update/delete). +- Typed readonly model classes under `Tesote\Sdk\Models\` for every + payload in the v1/v2 spec. +- New typed exception classes covering every remaining `error_code` + (account/transaction/payment-method/order/batch not-found, + invalid-cursor/count/limit/query, missing-date-range, sync-in-progress, + sync-rate-limit-exceeded, bank-under-maintenance, bank-connection-not- + found, validation, invalid-order-state, bank-submission-error, + batch-validation-error, internal-error). +- `Transport::requestRaw()` for endpoints returning non-JSON bodies + (transactions export). +- PHPUnit coverage: one test file per resource, plus the existing + Transport / Errors suites. + +### Removed +- `Tesote\Sdk\NotImplemented` — every resource is now wired. + ## 0.1.1 - 2026-04-28 ### Changed diff --git a/packages/php/README.md b/packages/php/README.md index 8875d7b..3fda78b 100644 --- a/packages/php/README.md +++ b/packages/php/README.md @@ -2,7 +2,7 @@ Official PHP client SDK for the [equipo.tesote.com](https://equipo.tesote.com) API. -Status: 0.1.0 — unreleased. v2 `accounts.list` / `accounts.get` are wired; everything else is stubbed and throws `LogicException`. +Status: 0.2.0 — full v1 + v2 surface (35 endpoints) wired with typed model objects and one exception class per `error_code`. ## Install @@ -22,9 +22,9 @@ $client = new Client([ 'apiKey' => getenv('TESOTE_API_KEY'), ]); -$accounts = $client->accounts->list(['limit' => 50]); -foreach ($accounts['data'] as $account) { - echo $account['id'], "\n"; +$accounts = $client->accounts->list(['per_page' => 50]); +foreach ($accounts->accounts as $account) { + echo $account->id, "\n"; } ``` @@ -43,7 +43,7 @@ Pick a version explicitly. `V1` stays shipped indefinitely. new V2Client([ 'apiKey' => '...', // required 'baseUrl' => 'https://equipo.tesote.com/api', // default - 'userAgent' => 'tesote-sdk-php/0.1.0 (php/8.x)', // override for Odoo/SAP connectors + 'userAgent' => 'tesote-sdk-php/0.2.0 (php/8.x)', // override for Odoo/SAP connectors 'maxAttempts' => 3, // retries on 429/5xx + transient network 'baseDelayMs' => 250, 'maxDelayMs' => 8000, diff --git a/packages/php/VERSION b/packages/php/VERSION index 17e51c3..0ea3a94 100644 --- a/packages/php/VERSION +++ b/packages/php/VERSION @@ -1 +1 @@ -0.1.1 +0.2.0 diff --git a/packages/php/src/Errors/AccountNotFoundException.php b/packages/php/src/Errors/AccountNotFoundException.php new file mode 100644 index 0000000..63aaf7c --- /dev/null +++ b/packages/php/src/Errors/AccountNotFoundException.php @@ -0,0 +1,10 @@ + MutationDuringPaginationException::class, 'UNPROCESSABLE_CONTENT' => UnprocessableContentException::class, 'INVALID_DATE_RANGE' => InvalidDateRangeException::class, + 'MISSING_DATE_RANGE' => MissingDateRangeException::class, + 'INVALID_CURSOR' => InvalidCursorException::class, + 'INVALID_COUNT' => InvalidCountException::class, + 'INVALID_LIMIT' => InvalidLimitException::class, + 'INVALID_QUERY' => InvalidQueryException::class, 'RATE_LIMIT_EXCEEDED' => RateLimitExceededException::class, + 'SYNC_RATE_LIMIT_EXCEEDED' => SyncRateLimitExceededException::class, + 'SYNC_IN_PROGRESS' => SyncInProgressException::class, + 'BANK_UNDER_MAINTENANCE' => BankUnderMaintenanceException::class, + 'BANK_CONNECTION_NOT_FOUND' => BankConnectionNotFoundException::class, + 'ACCOUNT_NOT_FOUND' => AccountNotFoundException::class, + 'TRANSACTION_NOT_FOUND' => TransactionNotFoundException::class, + 'SYNC_SESSION_NOT_FOUND' => SyncSessionNotFoundException::class, + 'PAYMENT_METHOD_NOT_FOUND' => PaymentMethodNotFoundException::class, + 'TRANSACTION_ORDER_NOT_FOUND' => TransactionOrderNotFoundException::class, + 'BATCH_NOT_FOUND' => BatchNotFoundException::class, + 'VALIDATION_ERROR' => ValidationException::class, + 'INVALID_ORDER_STATE' => InvalidOrderStateException::class, + 'BANK_SUBMISSION_ERROR' => BankSubmissionException::class, + 'BATCH_VALIDATION_ERROR' => BatchValidationException::class, + 'INTERNAL_ERROR' => InternalErrorException::class, default => self::class, }; } diff --git a/packages/php/src/Errors/BankConnectionNotFoundException.php b/packages/php/src/Errors/BankConnectionNotFoundException.php new file mode 100644 index 0000000..3cbd8b9 --- /dev/null +++ b/packages/php/src/Errors/BankConnectionNotFoundException.php @@ -0,0 +1,10 @@ +sleeper = $sleeper; + } + + public function delayMs(int $attempt, ?int $retryAfterSeconds): int + { + if ($retryAfterSeconds !== null) { + return min($this->maxDelayMs, $retryAfterSeconds * 1000); + } + $expo = $this->baseDelayMs * (2 ** ($attempt - 1)); + $capped = min($this->maxDelayMs, $expo); + // why: full jitter — distributes retries to avoid thundering herd. + return random_int(0, max(1, (int) $capped)); + } + + public function sleep(int $milliseconds): void + { + if ($milliseconds <= 0) { + return; + } + if ($this->sleeper !== null) { + ($this->sleeper)($milliseconds); + return; + } + usleep($milliseconds * 1000); + } +} diff --git a/packages/php/src/Internal/CacheKey.php b/packages/php/src/Internal/CacheKey.php new file mode 100644 index 0000000..ee83e44 --- /dev/null +++ b/packages/php/src/Internal/CacheKey.php @@ -0,0 +1,19 @@ + $body + */ + public static function encode(array $body): string + { + try { + return json_encode($body, self::ENCODE_FLAGS); + } catch (\JsonException $e) { + throw new ConfigException('Failed to JSON-encode request body: ' . $e->getMessage()); + } + } + + /** + * @return array|null + */ + public static function decode(string $body): ?array + { + if ($body === '') { + return null; + } + try { + $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return null; + } + return is_array($decoded) ? $decoded : null; + } +} diff --git a/packages/php/src/Internal/RateLimitTracker.php b/packages/php/src/Internal/RateLimitTracker.php new file mode 100644 index 0000000..bfe0cdc --- /dev/null +++ b/packages/php/src/Internal/RateLimitTracker.php @@ -0,0 +1,49 @@ + null, + 'remaining' => null, + 'reset' => null, + ]; + + private ?string $lastRequestId = null; + + /** + * @param array $headers + */ + public function record(array $headers): void + { + $this->lastRateLimit = [ + 'limit' => $headers['x-ratelimit-limit'] ?? null, + 'remaining' => $headers['x-ratelimit-remaining'] ?? null, + 'reset' => $headers['x-ratelimit-reset'] ?? null, + ]; + $this->lastRequestId = $headers['x-request-id'] ?? null; + } + + /** + * @return array{limit: ?string, remaining: ?string, reset: ?string} + */ + public function lastRateLimit(): array + { + return $this->lastRateLimit; + } + + public function lastRequestId(): ?string + { + return $this->lastRequestId; + } +} diff --git a/packages/php/src/Internal/RequestBuilder.php b/packages/php/src/Internal/RequestBuilder.php new file mode 100644 index 0000000..1f01208 --- /dev/null +++ b/packages/php/src/Internal/RequestBuilder.php @@ -0,0 +1,89 @@ +>|null $query + */ + public function buildUrl(string $path, ?array $query): string + { + $url = $this->baseUrl . '/' . ltrim($path, '/'); + if ($query !== null && $query !== []) { + $url .= '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986); + } + return $url; + } + + /** + * @param array $extra + * @return array + */ + public function defaultHeaders(array $extra): array + { + $headers = [ + 'Authorization' => 'Bearer ' . $this->apiKey, + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + 'User-Agent' => $this->userAgent, + ]; + foreach ($extra as $name => $value) { + $headers[$name] = $value; + } + return $headers; + } + + /** + * @param array $headers + * @return array + */ + public function buildCurlOptions(string $method, string $url, array $headers, ?string $encodedBody): array + { + $opts = [ + CURLOPT_URL => $url, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_CONNECTTIMEOUT_MS => $this->connectTimeoutMs, + CURLOPT_TIMEOUT_MS => $this->timeoutMs, + CURLOPT_HTTPHEADER => self::flattenHeaders($headers), + ]; + if ($encodedBody !== null) { + $opts[CURLOPT_POSTFIELDS] = $encodedBody; + } + if ($method === 'HEAD') { + $opts[CURLOPT_NOBODY] = true; + } + return $opts; + } + + /** + * @param array $headers + * @return list + */ + private static function flattenHeaders(array $headers): array + { + $out = []; + foreach ($headers as $name => $value) { + $out[] = $name . ': ' . $value; + } + return $out; + } +} diff --git a/packages/php/src/Internal/RequestSummarizer.php b/packages/php/src/Internal/RequestSummarizer.php new file mode 100644 index 0000000..4542abb --- /dev/null +++ b/packages/php/src/Internal/RequestSummarizer.php @@ -0,0 +1,52 @@ +>|null $query + * @param array|null $body + * + * @return array + */ + public function summarise(string $method, string $path, ?array $query, ?array $body): array + { + return [ + 'method' => $method, + 'path' => $path, + 'query' => $query, + 'bodyShape' => $body !== null ? $this->describeBody($body) : null, + 'auth' => 'Bearer ' . $this->lastFour(), + ]; + } + + /** + * @param array $body + * @return array + */ + private function describeBody(array $body): array + { + return [ + 'keys' => count($body), + 'type' => array_is_list($body) ? 'list' : 'object', + ]; + } + + private function lastFour(): string + { + return strlen($this->apiKey) <= 4 ? '****' : substr($this->apiKey, -4); + } +} diff --git a/packages/php/src/Internal/RetryPolicy.php b/packages/php/src/Internal/RetryPolicy.php new file mode 100644 index 0000000..51e0b00 --- /dev/null +++ b/packages/php/src/Internal/RetryPolicy.php @@ -0,0 +1,48 @@ +errno === CURLE_OPERATION_TIMEOUTED; + if ($isReadTimeout && self::isMutating($method) && $idempotencyKey === null) { + return false; + } + return CurlErrorClassifier::isRetriableErrno($result->errno); + } + + /** + * @param array $headers + */ + public static function parseRetryAfter(array $headers): ?int + { + if (isset($headers['retry-after']) && is_numeric($headers['retry-after'])) { + return (int) $headers['retry-after']; + } + return null; + } +} diff --git a/packages/php/src/Internal/TransportConfig.php b/packages/php/src/Internal/TransportConfig.php new file mode 100644 index 0000000..d0e98f9 --- /dev/null +++ b/packages/php/src/Internal/TransportConfig.php @@ -0,0 +1,75 @@ +): void)|null */ + public $logger; + /** @var (callable(int): void)|null */ + public $sleeper; + + /** + * @param array{ + * apiKey?: string, + * baseUrl?: string, + * userAgent?: string, + * maxAttempts?: int, + * baseDelayMs?: int, + * maxDelayMs?: int, + * connectTimeoutMs?: int, + * timeoutMs?: int, + * cache?: CacheBackend|null, + * curl?: CurlInterface|null, + * logger?: callable|null, + * sleeper?: callable|null, + * } $config + */ + public function __construct(array $config) + { + $apiKey = $config['apiKey'] ?? ''; + if (!is_string($apiKey) || $apiKey === '') { + throw new ConfigException('apiKey is required and must be a non-empty string.'); + } + $this->apiKey = $apiKey; + $this->baseUrl = rtrim((string) ($config['baseUrl'] ?? Transport::DEFAULT_BASE_URL), '/'); + $this->userAgent = (string) ($config['userAgent'] ?? sprintf( + 'tesote-sdk-php/%s (php/%s)', + Transport::VERSION, + PHP_VERSION, + )); + $this->maxAttempts = max(1, (int) ($config['maxAttempts'] ?? 3)); + $this->baseDelayMs = max(1, (int) ($config['baseDelayMs'] ?? 250)); + $this->maxDelayMs = max($this->baseDelayMs, (int) ($config['maxDelayMs'] ?? 8000)); + $this->connectTimeoutMs = max(1, (int) ($config['connectTimeoutMs'] ?? 5000)); + $this->timeoutMs = max(1, (int) ($config['timeoutMs'] ?? 30000)); + $this->cache = $config['cache'] ?? null; + $this->curl = $config['curl'] ?? new ExtCurl(); + $this->logger = $config['logger'] ?? null; + $this->sleeper = $config['sleeper'] ?? null; + } +} diff --git a/packages/php/src/Internal/Uuid.php b/packages/php/src/Internal/Uuid.php new file mode 100644 index 0000000..3b50850 --- /dev/null +++ b/packages/php/src/Internal/Uuid.php @@ -0,0 +1,30 @@ + $data + */ + public static function fromArray(array $data): self + { + $rawData = is_array($data['data'] ?? null) ? $data['data'] : []; + $rawBank = is_array($data['bank'] ?? null) ? $data['bank'] : []; + $rawLegal = is_array($data['legal_entity'] ?? null) ? $data['legal_entity'] : null; + + return new self( + id: (string) ($data['id'] ?? ''), + name: (string) ($data['name'] ?? ''), + data: AccountData::fromArray($rawData), + bank: Bank::fromArray($rawBank), + legalEntity: $rawLegal !== null ? LegalEntity::fromArray($rawLegal) : null, + tesoteCreatedAt: (string) ($data['tesote_created_at'] ?? ''), + tesoteUpdatedAt: (string) ($data['tesote_updated_at'] ?? ''), + ); + } +} diff --git a/packages/php/src/Models/AccountData.php b/packages/php/src/Models/AccountData.php new file mode 100644 index 0000000..a0355be --- /dev/null +++ b/packages/php/src/Models/AccountData.php @@ -0,0 +1,42 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self( + maskedAccountNumber: isset($data['masked_account_number']) ? (string) $data['masked_account_number'] : null, + currency: isset($data['currency']) ? (string) $data['currency'] : null, + transactionsDataCurrentAsOf: isset($data['transactions_data_current_as_of']) ? (string) $data['transactions_data_current_as_of'] : null, + balanceDataCurrentAsOf: isset($data['balance_data_current_as_of']) ? (string) $data['balance_data_current_as_of'] : null, + customUserProvidedIdentifier: isset($data['custom_user_provided_identifier']) ? (string) $data['custom_user_provided_identifier'] : null, + balanceCents: isset($data['balance_cents']) ? (string) $data['balance_cents'] : null, + availableBalanceCents: isset($data['available_balance_cents']) ? (string) $data['available_balance_cents'] : null, + ); + } +} diff --git a/packages/php/src/Models/AccountList.php b/packages/php/src/Models/AccountList.php new file mode 100644 index 0000000..df56c1a --- /dev/null +++ b/packages/php/src/Models/AccountList.php @@ -0,0 +1,39 @@ + $accounts + */ + public function __construct( + public int $total, + public array $accounts, + public PagePagination $pagination, + ) { + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $accounts = []; + foreach ((is_array($data['accounts'] ?? null) ? $data['accounts'] : []) as $entry) { + if (is_array($entry)) { + $accounts[] = Account::fromArray($entry); + } + } + $pagination = is_array($data['pagination'] ?? null) ? $data['pagination'] : []; + + return new self( + total: (int) ($data['total'] ?? 0), + accounts: $accounts, + pagination: PagePagination::fromArray($pagination), + ); + } +} diff --git a/packages/php/src/Models/Bank.php b/packages/php/src/Models/Bank.php new file mode 100644 index 0000000..39d16b7 --- /dev/null +++ b/packages/php/src/Models/Bank.php @@ -0,0 +1,22 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self(name: (string) ($data['name'] ?? '')); + } +} diff --git a/packages/php/src/Models/BatchActionResult.php b/packages/php/src/Models/BatchActionResult.php new file mode 100644 index 0000000..64d4f09 --- /dev/null +++ b/packages/php/src/Models/BatchActionResult.php @@ -0,0 +1,50 @@ +> $errors + */ + public function __construct( + public ?int $approved, + public ?int $enqueued, + public ?int $cancelled, + public ?int $skipped, + public ?int $failed, + public array $errors, + ) { + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $errors = []; + foreach ((is_array($data['errors'] ?? null) ? $data['errors'] : []) as $entry) { + if (is_array($entry)) { + /** @var array $entry */ + $errors[] = $entry; + } + } + + return new self( + approved: isset($data['approved']) ? (int) $data['approved'] : null, + enqueued: isset($data['enqueued']) ? (int) $data['enqueued'] : null, + cancelled: isset($data['cancelled']) ? (int) $data['cancelled'] : null, + skipped: isset($data['skipped']) ? (int) $data['skipped'] : null, + failed: isset($data['failed']) ? (int) $data['failed'] : null, + errors: $errors, + ); + } +} diff --git a/packages/php/src/Models/BatchCreated.php b/packages/php/src/Models/BatchCreated.php new file mode 100644 index 0000000..68597d7 --- /dev/null +++ b/packages/php/src/Models/BatchCreated.php @@ -0,0 +1,46 @@ + $orders + * @param list> $errors + */ + public function __construct( + public string $batchId, + public array $orders, + public array $errors, + ) { + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $orders = []; + foreach ((is_array($data['orders'] ?? null) ? $data['orders'] : []) as $entry) { + if (is_array($entry)) { + $orders[] = TransactionOrder::fromArray($entry); + } + } + $errors = []; + foreach ((is_array($data['errors'] ?? null) ? $data['errors'] : []) as $entry) { + if (is_array($entry)) { + /** @var array $entry */ + $errors[] = $entry; + } + } + + return new self( + batchId: (string) ($data['batch_id'] ?? ''), + orders: $orders, + errors: $errors, + ); + } +} diff --git a/packages/php/src/Models/BatchSummary.php b/packages/php/src/Models/BatchSummary.php new file mode 100644 index 0000000..8ce7963 --- /dev/null +++ b/packages/php/src/Models/BatchSummary.php @@ -0,0 +1,53 @@ + $statuses + * @param list $orders + */ + public function __construct( + public string $batchId, + public int $totalOrders, + public int $totalAmountCents, + public string $amountCurrency, + public array $statuses, + public string $batchStatus, + public string $createdAt, + public array $orders, + ) { + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $statuses = []; + foreach ((is_array($data['statuses'] ?? null) ? $data['statuses'] : []) as $status => $count) { + $statuses[(string) $status] = (int) $count; + } + $orders = []; + foreach ((is_array($data['orders'] ?? null) ? $data['orders'] : []) as $entry) { + if (is_array($entry)) { + $orders[] = TransactionOrder::fromArray($entry); + } + } + + return new self( + batchId: (string) ($data['batch_id'] ?? ''), + totalOrders: (int) ($data['total_orders'] ?? 0), + totalAmountCents: (int) ($data['total_amount_cents'] ?? 0), + amountCurrency: (string) ($data['amount_currency'] ?? ''), + statuses: $statuses, + batchStatus: (string) ($data['batch_status'] ?? ''), + createdAt: (string) ($data['created_at'] ?? ''), + orders: $orders, + ); + } +} diff --git a/packages/php/src/Models/BulkAccountResult.php b/packages/php/src/Models/BulkAccountResult.php new file mode 100644 index 0000000..a5d151e --- /dev/null +++ b/packages/php/src/Models/BulkAccountResult.php @@ -0,0 +1,39 @@ + $transactions + */ + public function __construct( + public string $accountId, + public array $transactions, + public CursorPagination $pagination, + ) { + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $transactions = []; + foreach ((is_array($data['transactions'] ?? null) ? $data['transactions'] : []) as $entry) { + if (is_array($entry)) { + $transactions[] = Transaction::fromArray($entry); + } + } + $pagination = is_array($data['pagination'] ?? null) ? $data['pagination'] : []; + + return new self( + accountId: (string) ($data['account_id'] ?? ''), + transactions: $transactions, + pagination: CursorPagination::fromArray($pagination), + ); + } +} diff --git a/packages/php/src/Models/BulkResult.php b/packages/php/src/Models/BulkResult.php new file mode 100644 index 0000000..16355bb --- /dev/null +++ b/packages/php/src/Models/BulkResult.php @@ -0,0 +1,32 @@ + $bulkResults + */ + public function __construct( + public array $bulkResults, + ) { + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $rows = []; + foreach ((is_array($data['bulk_results'] ?? null) ? $data['bulk_results'] : []) as $entry) { + if (is_array($entry)) { + $rows[] = BulkAccountResult::fromArray($entry); + } + } + + return new self(bulkResults: $rows); + } +} diff --git a/packages/php/src/Models/Counterparty.php b/packages/php/src/Models/Counterparty.php new file mode 100644 index 0000000..5d59a2e --- /dev/null +++ b/packages/php/src/Models/Counterparty.php @@ -0,0 +1,32 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self( + id: isset($data['id']) ? (string) $data['id'] : null, + name: (string) ($data['name'] ?? ''), + ); + } +} diff --git a/packages/php/src/Models/CursorPagination.php b/packages/php/src/Models/CursorPagination.php new file mode 100644 index 0000000..8b62525 --- /dev/null +++ b/packages/php/src/Models/CursorPagination.php @@ -0,0 +1,30 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self( + hasMore: (bool) ($data['has_more'] ?? false), + perPage: (int) ($data['per_page'] ?? 0), + afterId: isset($data['after_id']) ? (string) $data['after_id'] : null, + beforeId: isset($data['before_id']) ? (string) $data['before_id'] : null, + ); + } +} diff --git a/packages/php/src/Models/Destination.php b/packages/php/src/Models/Destination.php new file mode 100644 index 0000000..d0c314b --- /dev/null +++ b/packages/php/src/Models/Destination.php @@ -0,0 +1,28 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self( + paymentMethodId: (string) ($data['payment_method_id'] ?? ''), + counterpartyId: isset($data['counterparty_id']) ? (string) $data['counterparty_id'] : null, + counterpartyName: isset($data['counterparty_name']) ? (string) $data['counterparty_name'] : null, + ); + } +} diff --git a/packages/php/src/Models/ExportFile.php b/packages/php/src/Models/ExportFile.php new file mode 100644 index 0000000..14a1e54 --- /dev/null +++ b/packages/php/src/Models/ExportFile.php @@ -0,0 +1,22 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self( + id: isset($data['id']) ? (string) $data['id'] : null, + legalName: isset($data['legal_name']) ? (string) $data['legal_name'] : null, + ); + } +} diff --git a/packages/php/src/Models/Money.php b/packages/php/src/Models/Money.php new file mode 100644 index 0000000..84a0894 --- /dev/null +++ b/packages/php/src/Models/Money.php @@ -0,0 +1,31 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self( + amount: (string) ($data['amount'] ?? '0'), + currency: (string) ($data['currency'] ?? ''), + ); + } +} diff --git a/packages/php/src/Models/PagePagination.php b/packages/php/src/Models/PagePagination.php new file mode 100644 index 0000000..6ddcc14 --- /dev/null +++ b/packages/php/src/Models/PagePagination.php @@ -0,0 +1,30 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self( + currentPage: (int) ($data['current_page'] ?? 0), + perPage: (int) ($data['per_page'] ?? 0), + totalPages: (int) ($data['total_pages'] ?? 0), + totalCount: (int) ($data['total_count'] ?? 0), + ); + } +} diff --git a/packages/php/src/Models/PaymentMethod.php b/packages/php/src/Models/PaymentMethod.php new file mode 100644 index 0000000..30d22b9 --- /dev/null +++ b/packages/php/src/Models/PaymentMethod.php @@ -0,0 +1,53 @@ + $details + */ + public function __construct( + public string $id, + public string $methodType, + public string $currency, + public ?string $label, + public array $details, + public bool $verified, + public ?string $verifiedAt, + public ?string $lastUsedAt, + public ?Counterparty $counterparty, + public ?TesoteAccountRef $tesoteAccount, + public string $createdAt, + public string $updatedAt, + ) { + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $rawDetails = is_array($data['details'] ?? null) ? $data['details'] : []; + $rawCounterparty = is_array($data['counterparty'] ?? null) ? $data['counterparty'] : null; + $rawAccount = is_array($data['tesote_account'] ?? null) ? $data['tesote_account'] : null; + + return new self( + id: (string) ($data['id'] ?? ''), + methodType: (string) ($data['method_type'] ?? ''), + currency: (string) ($data['currency'] ?? ''), + label: isset($data['label']) ? (string) $data['label'] : null, + details: $rawDetails, + verified: (bool) ($data['verified'] ?? false), + verifiedAt: isset($data['verified_at']) ? (string) $data['verified_at'] : null, + lastUsedAt: isset($data['last_used_at']) ? (string) $data['last_used_at'] : null, + counterparty: $rawCounterparty !== null ? Counterparty::fromArray($rawCounterparty) : null, + tesoteAccount: $rawAccount !== null ? TesoteAccountRef::fromArray($rawAccount) : null, + createdAt: (string) ($data['created_at'] ?? ''), + updatedAt: (string) ($data['updated_at'] ?? ''), + ); + } +} diff --git a/packages/php/src/Models/PaymentMethodList.php b/packages/php/src/Models/PaymentMethodList.php new file mode 100644 index 0000000..dba92f7 --- /dev/null +++ b/packages/php/src/Models/PaymentMethodList.php @@ -0,0 +1,40 @@ + $items + */ + public function __construct( + public array $items, + public bool $hasMore, + public int $limit, + public int $offset, + ) { + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $items = []; + foreach ((is_array($data['items'] ?? null) ? $data['items'] : []) as $entry) { + if (is_array($entry)) { + $items[] = PaymentMethod::fromArray($entry); + } + } + + return new self( + items: $items, + hasMore: (bool) ($data['has_more'] ?? false), + limit: (int) ($data['limit'] ?? 0), + offset: (int) ($data['offset'] ?? 0), + ); + } +} diff --git a/packages/php/src/Models/SourceAccount.php b/packages/php/src/Models/SourceAccount.php new file mode 100644 index 0000000..ef4b927 --- /dev/null +++ b/packages/php/src/Models/SourceAccount.php @@ -0,0 +1,28 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self( + id: (string) ($data['id'] ?? ''), + name: (string) ($data['name'] ?? ''), + paymentMethodId: (string) ($data['payment_method_id'] ?? ''), + ); + } +} diff --git a/packages/php/src/Models/StatusInfo.php b/packages/php/src/Models/StatusInfo.php new file mode 100644 index 0000000..8b00474 --- /dev/null +++ b/packages/php/src/Models/StatusInfo.php @@ -0,0 +1,26 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self( + status: (string) ($data['status'] ?? ''), + authenticated: (bool) ($data['authenticated'] ?? false), + ); + } +} diff --git a/packages/php/src/Models/SyncRemoval.php b/packages/php/src/Models/SyncRemoval.php new file mode 100644 index 0000000..c257b83 --- /dev/null +++ b/packages/php/src/Models/SyncRemoval.php @@ -0,0 +1,26 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self( + transactionId: (string) ($data['transaction_id'] ?? ''), + accountId: (string) ($data['account_id'] ?? ''), + ); + } +} diff --git a/packages/php/src/Models/SyncResult.php b/packages/php/src/Models/SyncResult.php new file mode 100644 index 0000000..e1207ac --- /dev/null +++ b/packages/php/src/Models/SyncResult.php @@ -0,0 +1,56 @@ + $added + * @param list $modified + * @param list $removed + */ + public function __construct( + public array $added, + public array $modified, + public array $removed, + public ?string $nextCursor, + public bool $hasMore, + ) { + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $added = []; + foreach ((is_array($data['added'] ?? null) ? $data['added'] : []) as $entry) { + if (is_array($entry)) { + $added[] = SyncTransaction::fromArray($entry); + } + } + $modified = []; + foreach ((is_array($data['modified'] ?? null) ? $data['modified'] : []) as $entry) { + if (is_array($entry)) { + $modified[] = SyncTransaction::fromArray($entry); + } + } + $removed = []; + foreach ((is_array($data['removed'] ?? null) ? $data['removed'] : []) as $entry) { + if (is_array($entry)) { + $removed[] = SyncRemoval::fromArray($entry); + } + } + + return new self( + added: $added, + modified: $modified, + removed: $removed, + nextCursor: isset($data['next_cursor']) ? (string) $data['next_cursor'] : null, + hasMore: (bool) ($data['has_more'] ?? false), + ); + } +} diff --git a/packages/php/src/Models/SyncSession.php b/packages/php/src/Models/SyncSession.php new file mode 100644 index 0000000..a8d2416 --- /dev/null +++ b/packages/php/src/Models/SyncSession.php @@ -0,0 +1,58 @@ + $data + */ + public static function fromArray(array $data): self + { + $error = null; + if (is_array($data['error'] ?? null)) { + $error = [ + 'type' => (string) ($data['error']['type'] ?? ''), + 'message' => (string) ($data['error']['message'] ?? ''), + ]; + } + $performance = null; + if (is_array($data['performance'] ?? null)) { + $performance = [ + 'total_duration' => (float) ($data['performance']['total_duration'] ?? 0.0), + 'complexity_score' => (float) ($data['performance']['complexity_score'] ?? 0.0), + 'sync_speed_score' => (float) ($data['performance']['sync_speed_score'] ?? 0.0), + ]; + } + + return new self( + id: (string) ($data['id'] ?? ''), + status: (string) ($data['status'] ?? ''), + startedAt: (string) ($data['started_at'] ?? ''), + completedAt: isset($data['completed_at']) ? (string) $data['completed_at'] : null, + transactionsSynced: (int) ($data['transactions_synced'] ?? 0), + accountsCount: (int) ($data['accounts_count'] ?? 0), + error: $error, + performance: $performance, + ); + } +} diff --git a/packages/php/src/Models/SyncSessionList.php b/packages/php/src/Models/SyncSessionList.php new file mode 100644 index 0000000..2b3a0a2 --- /dev/null +++ b/packages/php/src/Models/SyncSessionList.php @@ -0,0 +1,40 @@ + $syncSessions + */ + public function __construct( + public array $syncSessions, + public int $limit, + public int $offset, + public bool $hasMore, + ) { + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $sessions = []; + foreach ((is_array($data['sync_sessions'] ?? null) ? $data['sync_sessions'] : []) as $entry) { + if (is_array($entry)) { + $sessions[] = SyncSession::fromArray($entry); + } + } + + return new self( + syncSessions: $sessions, + limit: (int) ($data['limit'] ?? 0), + offset: (int) ($data['offset'] ?? 0), + hasMore: (bool) ($data['has_more'] ?? false), + ); + } +} diff --git a/packages/php/src/Models/SyncStarted.php b/packages/php/src/Models/SyncStarted.php new file mode 100644 index 0000000..010a32f --- /dev/null +++ b/packages/php/src/Models/SyncStarted.php @@ -0,0 +1,30 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self( + message: (string) ($data['message'] ?? ''), + syncSessionId: (string) ($data['sync_session_id'] ?? ''), + status: (string) ($data['status'] ?? ''), + startedAt: (string) ($data['started_at'] ?? ''), + ); + } +} diff --git a/packages/php/src/Models/SyncTransaction.php b/packages/php/src/Models/SyncTransaction.php new file mode 100644 index 0000000..d95e23d --- /dev/null +++ b/packages/php/src/Models/SyncTransaction.php @@ -0,0 +1,58 @@ + $category + */ + public function __construct( + public string $transactionId, + public string $accountId, + public float $amount, + public string $isoCurrencyCode, + public ?string $unofficialCurrencyCode, + public string $date, + public ?string $datetime, + public string $name, + public ?string $merchantName, + public bool $pending, + public array $category, + public ?int $runningBalanceCents, + ) { + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $rawCategory = is_array($data['category'] ?? null) ? $data['category'] : []; + $category = []; + foreach ($rawCategory as $entry) { + $category[] = (string) $entry; + } + + return new self( + transactionId: (string) ($data['transaction_id'] ?? ''), + accountId: (string) ($data['account_id'] ?? ''), + amount: (float) ($data['amount'] ?? 0.0), + isoCurrencyCode: (string) ($data['iso_currency_code'] ?? ''), + unofficialCurrencyCode: isset($data['unofficial_currency_code']) ? (string) $data['unofficial_currency_code'] : null, + date: (string) ($data['date'] ?? ''), + datetime: isset($data['datetime']) ? (string) $data['datetime'] : null, + name: (string) ($data['name'] ?? ''), + merchantName: isset($data['merchant_name']) ? (string) $data['merchant_name'] : null, + pending: (bool) ($data['pending'] ?? false), + category: $category, + runningBalanceCents: isset($data['running_balance_cents']) ? (int) $data['running_balance_cents'] : null, + ); + } +} diff --git a/packages/php/src/Models/TesoteAccountRef.php b/packages/php/src/Models/TesoteAccountRef.php new file mode 100644 index 0000000..3242622 --- /dev/null +++ b/packages/php/src/Models/TesoteAccountRef.php @@ -0,0 +1,26 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self( + id: (string) ($data['id'] ?? ''), + name: (string) ($data['name'] ?? ''), + ); + } +} diff --git a/packages/php/src/Models/TesoteTransactionRef.php b/packages/php/src/Models/TesoteTransactionRef.php new file mode 100644 index 0000000..4efa87b --- /dev/null +++ b/packages/php/src/Models/TesoteTransactionRef.php @@ -0,0 +1,26 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self( + id: (string) ($data['id'] ?? ''), + status: (string) ($data['status'] ?? ''), + ); + } +} diff --git a/packages/php/src/Models/Transaction.php b/packages/php/src/Models/Transaction.php new file mode 100644 index 0000000..27f5ff5 --- /dev/null +++ b/packages/php/src/Models/Transaction.php @@ -0,0 +1,50 @@ + $transactionCategories + */ + public function __construct( + public string $id, + public string $status, + public TransactionData $data, + public string $tesoteImportedAt, + public string $tesoteUpdatedAt, + public array $transactionCategories, + public ?Counterparty $counterparty, + ) { + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $rawData = is_array($data['data'] ?? null) ? $data['data'] : []; + $rawCategories = is_array($data['transaction_categories'] ?? null) ? $data['transaction_categories'] : []; + $rawCounterparty = is_array($data['counterparty'] ?? null) ? $data['counterparty'] : null; + + $categories = []; + foreach ($rawCategories as $entry) { + if (is_array($entry)) { + $categories[] = TransactionCategory::fromArray($entry); + } + } + + return new self( + id: (string) ($data['id'] ?? ''), + status: (string) ($data['status'] ?? ''), + data: TransactionData::fromArray($rawData), + tesoteImportedAt: (string) ($data['tesote_imported_at'] ?? ''), + tesoteUpdatedAt: (string) ($data['tesote_updated_at'] ?? ''), + transactionCategories: $categories, + counterparty: $rawCounterparty !== null ? Counterparty::fromArray($rawCounterparty) : null, + ); + } +} diff --git a/packages/php/src/Models/TransactionCategory.php b/packages/php/src/Models/TransactionCategory.php new file mode 100644 index 0000000..8bcc6c3 --- /dev/null +++ b/packages/php/src/Models/TransactionCategory.php @@ -0,0 +1,30 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self( + name: (string) ($data['name'] ?? ''), + externalCategoryCode: isset($data['external_category_code']) ? (string) $data['external_category_code'] : null, + createdAt: (string) ($data['created_at'] ?? ''), + updatedAt: (string) ($data['updated_at'] ?? ''), + ); + } +} diff --git a/packages/php/src/Models/TransactionData.php b/packages/php/src/Models/TransactionData.php new file mode 100644 index 0000000..5aac68a --- /dev/null +++ b/packages/php/src/Models/TransactionData.php @@ -0,0 +1,45 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self( + amountCents: (int) ($data['amount_cents'] ?? 0), + currency: (string) ($data['currency'] ?? ''), + description: (string) ($data['description'] ?? ''), + transactionDate: (string) ($data['transaction_date'] ?? ''), + createdAt: isset($data['created_at']) ? (string) $data['created_at'] : null, + createdAtDate: isset($data['created_at_date']) ? (string) $data['created_at_date'] : null, + note: isset($data['note']) ? (string) $data['note'] : null, + externalServiceId: isset($data['external_service_id']) ? (string) $data['external_service_id'] : null, + runningBalanceCents: isset($data['running_balance_cents']) ? (int) $data['running_balance_cents'] : null, + ); + } +} diff --git a/packages/php/src/Models/TransactionList.php b/packages/php/src/Models/TransactionList.php new file mode 100644 index 0000000..a773763 --- /dev/null +++ b/packages/php/src/Models/TransactionList.php @@ -0,0 +1,39 @@ + $transactions + */ + public function __construct( + public int $total, + public array $transactions, + public CursorPagination $pagination, + ) { + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $transactions = []; + foreach ((is_array($data['transactions'] ?? null) ? $data['transactions'] : []) as $entry) { + if (is_array($entry)) { + $transactions[] = Transaction::fromArray($entry); + } + } + $pagination = is_array($data['pagination'] ?? null) ? $data['pagination'] : []; + + return new self( + total: (int) ($data['total'] ?? 0), + transactions: $transactions, + pagination: CursorPagination::fromArray($pagination), + ); + } +} diff --git a/packages/php/src/Models/TransactionOrder.php b/packages/php/src/Models/TransactionOrder.php new file mode 100644 index 0000000..3d0ca46 --- /dev/null +++ b/packages/php/src/Models/TransactionOrder.php @@ -0,0 +1,74 @@ + $data + */ + public static function fromArray(array $data): self + { + $source = is_array($data['source_account'] ?? null) ? $data['source_account'] : []; + $dest = is_array($data['destination'] ?? null) ? $data['destination'] : []; + $fee = is_array($data['fee'] ?? null) ? $data['fee'] : null; + $tesoteTxn = is_array($data['tesote_transaction'] ?? null) ? $data['tesote_transaction'] : null; + $attempt = is_array($data['latest_attempt'] ?? null) ? $data['latest_attempt'] : null; + + return new self( + id: (string) ($data['id'] ?? ''), + status: (string) ($data['status'] ?? ''), + amount: (string) ($data['amount'] ?? '0'), + currency: (string) ($data['currency'] ?? ''), + description: (string) ($data['description'] ?? ''), + reference: isset($data['reference']) ? (string) $data['reference'] : null, + externalReference: isset($data['external_reference']) ? (string) $data['external_reference'] : null, + idempotencyKey: isset($data['idempotency_key']) ? (string) $data['idempotency_key'] : null, + batchId: isset($data['batch_id']) ? (string) $data['batch_id'] : null, + scheduledFor: isset($data['scheduled_for']) ? (string) $data['scheduled_for'] : null, + approvedAt: isset($data['approved_at']) ? (string) $data['approved_at'] : null, + submittedAt: isset($data['submitted_at']) ? (string) $data['submitted_at'] : null, + completedAt: isset($data['completed_at']) ? (string) $data['completed_at'] : null, + failedAt: isset($data['failed_at']) ? (string) $data['failed_at'] : null, + cancelledAt: isset($data['cancelled_at']) ? (string) $data['cancelled_at'] : null, + sourceAccount: SourceAccount::fromArray($source), + destination: Destination::fromArray($dest), + fee: $fee !== null ? Money::fromArray($fee) : null, + executionStrategy: isset($data['execution_strategy']) ? (string) $data['execution_strategy'] : null, + tesoteTransaction: $tesoteTxn !== null ? TesoteTransactionRef::fromArray($tesoteTxn) : null, + latestAttempt: $attempt !== null ? TransactionOrderAttempt::fromArray($attempt) : null, + createdAt: (string) ($data['created_at'] ?? ''), + updatedAt: (string) ($data['updated_at'] ?? ''), + ); + } +} diff --git a/packages/php/src/Models/TransactionOrderAttempt.php b/packages/php/src/Models/TransactionOrderAttempt.php new file mode 100644 index 0000000..67aefe8 --- /dev/null +++ b/packages/php/src/Models/TransactionOrderAttempt.php @@ -0,0 +1,38 @@ + $data + */ + public static function fromArray(array $data): self + { + return new self( + id: (string) ($data['id'] ?? ''), + status: (string) ($data['status'] ?? ''), + attemptNumber: (int) ($data['attempt_number'] ?? 0), + externalReference: isset($data['external_reference']) ? (string) $data['external_reference'] : null, + submittedAt: isset($data['submitted_at']) ? (string) $data['submitted_at'] : null, + completedAt: isset($data['completed_at']) ? (string) $data['completed_at'] : null, + errorCode: isset($data['error_code']) ? (string) $data['error_code'] : null, + errorMessage: isset($data['error_message']) ? (string) $data['error_message'] : null, + ); + } +} diff --git a/packages/php/src/Models/TransactionOrderList.php b/packages/php/src/Models/TransactionOrderList.php new file mode 100644 index 0000000..47c77dc --- /dev/null +++ b/packages/php/src/Models/TransactionOrderList.php @@ -0,0 +1,40 @@ + $items + */ + public function __construct( + public array $items, + public bool $hasMore, + public int $limit, + public int $offset, + ) { + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $items = []; + foreach ((is_array($data['items'] ?? null) ? $data['items'] : []) as $entry) { + if (is_array($entry)) { + $items[] = TransactionOrder::fromArray($entry); + } + } + + return new self( + items: $items, + hasMore: (bool) ($data['has_more'] ?? false), + limit: (int) ($data['limit'] ?? 0), + offset: (int) ($data['offset'] ?? 0), + ); + } +} diff --git a/packages/php/src/Models/TransactionSearchResult.php b/packages/php/src/Models/TransactionSearchResult.php new file mode 100644 index 0000000..d49c667 --- /dev/null +++ b/packages/php/src/Models/TransactionSearchResult.php @@ -0,0 +1,36 @@ + $transactions + */ + public function __construct( + public array $transactions, + public int $total, + ) { + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self + { + $transactions = []; + foreach ((is_array($data['transactions'] ?? null) ? $data['transactions'] : []) as $entry) { + if (is_array($entry)) { + $transactions[] = Transaction::fromArray($entry); + } + } + + return new self( + transactions: $transactions, + total: (int) ($data['total'] ?? 0), + ); + } +} diff --git a/packages/php/src/Models/WhoAmI.php b/packages/php/src/Models/WhoAmI.php new file mode 100644 index 0000000..fefbc44 --- /dev/null +++ b/packages/php/src/Models/WhoAmI.php @@ -0,0 +1,29 @@ + $data + */ + public static function fromArray(array $data): self + { + $client = is_array($data['client'] ?? null) ? $data['client'] : []; + return new self( + id: (string) ($client['id'] ?? ''), + name: (string) ($client['name'] ?? ''), + type: (string) ($client['type'] ?? ''), + ); + } +} diff --git a/packages/php/src/NotImplemented.php b/packages/php/src/NotImplemented.php deleted file mode 100644 index 73815cc..0000000 --- a/packages/php/src/NotImplemented.php +++ /dev/null @@ -1,35 +0,0 @@ - $arguments - * @throws LogicException - */ - public function __call(string $name, array $arguments): never - { - throw new LogicException(sprintf( - 'not implemented: %s.%s() — wiring pending in this SDK version', - $this->resource, - $name, - )); - } -} diff --git a/packages/php/src/Transport.php b/packages/php/src/Transport.php index 80ae8ce..8ec2f62 100644 --- a/packages/php/src/Transport.php +++ b/packages/php/src/Transport.php @@ -6,11 +6,17 @@ use Tesote\Sdk\Cache\CacheBackend; use Tesote\Sdk\Errors\ApiException; -use Tesote\Sdk\Errors\ConfigException; use Tesote\Sdk\Http\CurlErrorClassifier; use Tesote\Sdk\Http\CurlInterface; -use Tesote\Sdk\Http\CurlResult; -use Tesote\Sdk\Http\ExtCurl; +use Tesote\Sdk\Internal\BackoffStrategy; +use Tesote\Sdk\Internal\CacheKey; +use Tesote\Sdk\Internal\JsonCodec; +use Tesote\Sdk\Internal\RateLimitTracker; +use Tesote\Sdk\Internal\RequestBuilder; +use Tesote\Sdk\Internal\RequestSummarizer; +use Tesote\Sdk\Internal\RetryPolicy; +use Tesote\Sdk\Internal\TransportConfig; +use Tesote\Sdk\Internal\Uuid; /** * Single HTTP client for the SDK. Resource clients call request() and get @@ -18,39 +24,25 @@ * * Owns: bearer injection, retries with exp-backoff + jitter, rate-limit * header capture, idempotency-key auto-gen for mutations, request-id - * propagation into exceptions, optional TTL cache. - * - * Configuration is passed as a single associative array to mirror the - * shape used by the language-specific Client constructors. + * propagation into exceptions, optional TTL cache. Cross-cutting concerns + * live in single-purpose collaborators in Tesote\Sdk\Internal\*. */ final class Transport { - public const VERSION = '0.1.0'; + public const VERSION = '0.2.0'; public const DEFAULT_BASE_URL = 'https://equipo.tesote.com/api'; private readonly string $apiKey; - private readonly string $baseUrl; - private readonly string $userAgent; private readonly int $maxAttempts; - private readonly int $baseDelayMs; - private readonly int $maxDelayMs; - private readonly int $connectTimeoutMs; - private readonly int $timeoutMs; private readonly ?CacheBackend $cache; private readonly CurlInterface $curl; /** @var (callable(string, string, array): void)|null */ private $logger; - /** @var (callable(int): void)|null */ - private $sleeper; - - /** @var array{limit: ?string, remaining: ?string, reset: ?string} */ - private array $lastRateLimit = [ - 'limit' => null, - 'remaining' => null, - 'reset' => null, - ]; - private ?string $lastRequestId = null; + private readonly RequestBuilder $builder; + private readonly BackoffStrategy $backoff; + private readonly RequestSummarizer $summarizer; + private readonly RateLimitTracker $rateLimit; /** * @param array{ @@ -70,26 +62,23 @@ final class Transport */ public function __construct(array $config) { - $apiKey = $config['apiKey'] ?? ''; - if (!is_string($apiKey) || $apiKey === '') { - throw new ConfigException('apiKey is required and must be a non-empty string.'); - } - $this->apiKey = $apiKey; - $this->baseUrl = rtrim((string) ($config['baseUrl'] ?? self::DEFAULT_BASE_URL), '/'); - $this->userAgent = (string) ($config['userAgent'] ?? sprintf( - 'tesote-sdk-php/%s (php/%s)', - self::VERSION, - PHP_VERSION, - )); - $this->maxAttempts = max(1, (int) ($config['maxAttempts'] ?? 3)); - $this->baseDelayMs = max(1, (int) ($config['baseDelayMs'] ?? 250)); - $this->maxDelayMs = max($this->baseDelayMs, (int) ($config['maxDelayMs'] ?? 8000)); - $this->connectTimeoutMs = max(1, (int) ($config['connectTimeoutMs'] ?? 5000)); - $this->timeoutMs = max(1, (int) ($config['timeoutMs'] ?? 30000)); - $this->cache = $config['cache'] ?? null; - $this->curl = $config['curl'] ?? new ExtCurl(); - $this->logger = $config['logger'] ?? null; - $this->sleeper = $config['sleeper'] ?? null; + $cfg = new TransportConfig($config); + $this->apiKey = $cfg->apiKey; + $this->maxAttempts = $cfg->maxAttempts; + $this->cache = $cfg->cache; + $this->curl = $cfg->curl; + $this->logger = $cfg->logger; + + $this->builder = new RequestBuilder( + baseUrl: $cfg->baseUrl, + apiKey: $cfg->apiKey, + userAgent: $cfg->userAgent, + connectTimeoutMs: $cfg->connectTimeoutMs, + timeoutMs: $cfg->timeoutMs, + ); + $this->backoff = new BackoffStrategy($cfg->baseDelayMs, $cfg->maxDelayMs, $cfg->sleeper); + $this->summarizer = new RequestSummarizer($cfg->apiKey); + $this->rateLimit = new RateLimitTracker(); } /** @@ -115,64 +104,52 @@ public function request( array $opts = [], ): ?array { $method = strtoupper($method); - $url = $this->buildUrl($path, $query); - $isMutation = in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true); - $cacheKey = $this->cacheKey($method, $url); + $url = $this->builder->buildUrl($path, $query); + $isMutation = RetryPolicy::isMutating($method); + $cacheKey = CacheKey::for($method, $url, $this->apiKey); $cacheTtl = isset($opts['cacheTtl']) ? (int) $opts['cacheTtl'] : 0; if (!$isMutation && $this->cache !== null && $cacheTtl > 0) { $hit = $this->cache->get($cacheKey); if ($hit !== null) { - $this->captureRateLimit($hit['headers']); - $this->lastRequestId = $hit['headers']['x-request-id'] ?? null; + $this->rateLimit->record($hit['headers']); return is_array($hit['body']) ? $hit['body'] : null; } } $idempotencyKey = $opts['idempotencyKey'] ?? null; if ($isMutation && $idempotencyKey === null) { - $idempotencyKey = self::generateUuidV4(); + $idempotencyKey = Uuid::v4(); } - $headers = $this->defaultHeaders($opts['headers'] ?? []); + $headers = $this->builder->defaultHeaders($opts['headers'] ?? []); if ($idempotencyKey !== null) { $headers['Idempotency-Key'] = $idempotencyKey; } - $encodedBody = null; - if ($body !== null) { - try { - $encodedBody = json_encode($body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - } catch (\JsonException $e) { - throw new ConfigException('Failed to JSON-encode request body: ' . $e->getMessage()); - } - } - - $summary = $this->summarise($method, $path, $query, $body); + $encodedBody = $body !== null ? JsonCodec::encode($body) : null; + $summary = $this->summarizer->summarise($method, $path, $query, $body); $lastException = null; - $lastResult = null; for ($attempt = 1; $attempt <= $this->maxAttempts; $attempt++) { - $options = $this->buildCurlOptions($method, $url, $headers, $encodedBody); + $options = $this->builder->buildCurlOptions($method, $url, $headers, $encodedBody); $result = $this->curl->execute($options); - $lastResult = $result; $this->log($method, $url, ['status' => $result->status, 'attempt' => $attempt]); if ($result->errno !== 0) { $exception = CurlErrorClassifier::classify($result, $summary, $attempt); $lastException = $exception; - if ($this->shouldRetryTransport($result, $method, $idempotencyKey) && $attempt < $this->maxAttempts) { - $this->sleep($this->backoffDelay($attempt, null)); + if (RetryPolicy::shouldRetryTransport($result, $method, $idempotencyKey) && $attempt < $this->maxAttempts) { + $this->backoff->sleep($this->backoff->delayMs($attempt, null)); continue; } throw $exception; } - $this->captureRateLimit($result->headers); - $this->lastRequestId = $result->headers['x-request-id'] ?? null; + $this->rateLimit->record($result->headers); if ($result->status >= 200 && $result->status < 300) { - $decoded = $this->decodeBody($result->body); + $decoded = JsonCodec::decode($result->body); if (!$isMutation && $this->cache !== null && $cacheTtl > 0) { $this->cache->set($cacheKey, [ 'body' => $decoded, @@ -181,14 +158,14 @@ public function request( ], $cacheTtl); } elseif ($isMutation && $this->cache !== null) { // why: any mutation on the resource path should bust the matching GET cache entry. - $this->cache->delete($this->cacheKey('GET', $url)); + $this->cache->delete(CacheKey::for('GET', $url, $this->apiKey)); } return $decoded; } - if ($this->isRetriableStatus($result->status) && $attempt < $this->maxAttempts) { - $delay = $this->backoffDelay($attempt, $this->parseRetryAfter($result->headers)); - $this->sleep($delay); + if (RetryPolicy::isRetriableStatus($result->status) && $attempt < $this->maxAttempts) { + $delay = $this->backoff->delayMs($attempt, RetryPolicy::parseRetryAfter($result->headers)); + $this->backoff->sleep($delay); continue; } @@ -209,218 +186,107 @@ public function request( } /** - * @return array{limit: ?string, remaining: ?string, reset: ?string} - */ - public function getLastRateLimit(): array - { - return $this->lastRateLimit; - } - - public function getLastRequestId(): ?string - { - return $this->lastRequestId; - } - - public static function generateUuidV4(): string - { - $bytes = random_bytes(16); - $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40); - $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); - $hex = bin2hex($bytes); - return sprintf( - '%s-%s-%s-%s-%s', - substr($hex, 0, 8), - substr($hex, 8, 4), - substr($hex, 12, 4), - substr($hex, 16, 4), - substr($hex, 20, 12), - ); - } - - /** + * Like request() but returns the raw body string + response headers. + * + * Used by export endpoints that return CSV / pretty-printed JSON files + * rather than the standard JSON envelope. Skips the response cache. + * * @param array>|null $query + * @param array|null $body + * @param array{ + * idempotencyKey?: string|null, + * headers?: array, + * } $opts + * + * @return array{body: string, headers: array, status: int} */ - private function buildUrl(string $path, ?array $query): string - { - $url = $this->baseUrl . '/' . ltrim($path, '/'); - if ($query !== null && $query !== []) { - $url .= '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986); - } - return $url; - } + public function requestRaw( + string $method, + string $path, + ?array $query = null, + ?array $body = null, + array $opts = [], + ): array { + $method = strtoupper($method); + $url = $this->builder->buildUrl($path, $query); + $isMutation = RetryPolicy::isMutating($method); - /** - * @param array $extra - * @return array - */ - private function defaultHeaders(array $extra): array - { - $headers = [ - 'Authorization' => 'Bearer ' . $this->apiKey, - 'Accept' => 'application/json', - 'Content-Type' => 'application/json', - 'User-Agent' => $this->userAgent, - ]; - foreach ($extra as $name => $value) { - $headers[$name] = $value; + $idempotencyKey = $opts['idempotencyKey'] ?? null; + if ($isMutation && $idempotencyKey === null) { + $idempotencyKey = Uuid::v4(); } - return $headers; - } - /** - * @param array $headers - * @return array - */ - private function buildCurlOptions(string $method, string $url, array $headers, ?string $encodedBody): array - { - $opts = [ - CURLOPT_URL => $url, - CURLOPT_CUSTOMREQUEST => $method, - CURLOPT_FOLLOWLOCATION => false, - CURLOPT_CONNECTTIMEOUT_MS => $this->connectTimeoutMs, - CURLOPT_TIMEOUT_MS => $this->timeoutMs, - CURLOPT_HTTPHEADER => $this->flattenHeaders($headers), - ]; - if ($encodedBody !== null) { - $opts[CURLOPT_POSTFIELDS] = $encodedBody; - } - if ($method === 'HEAD') { - $opts[CURLOPT_NOBODY] = true; + $headers = $this->builder->defaultHeaders($opts['headers'] ?? []); + if ($idempotencyKey !== null) { + $headers['Idempotency-Key'] = $idempotencyKey; } - return $opts; - } - /** - * @param array $headers - * @return list - */ - private function flattenHeaders(array $headers): array - { - $out = []; - foreach ($headers as $name => $value) { - $out[] = $name . ': ' . $value; - } - return $out; - } + $encodedBody = $body !== null ? JsonCodec::encode($body) : null; + $summary = $this->summarizer->summarise($method, $path, $query, $body); + $lastException = null; - /** - * @return array|null - */ - private function decodeBody(string $body): ?array - { - if ($body === '') { - return null; - } - try { - $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR); - } catch (\JsonException) { - return null; - } - return is_array($decoded) ? $decoded : null; - } + for ($attempt = 1; $attempt <= $this->maxAttempts; $attempt++) { + $options = $this->builder->buildCurlOptions($method, $url, $headers, $encodedBody); + $result = $this->curl->execute($options); + $this->log($method, $url, ['status' => $result->status, 'attempt' => $attempt]); - /** - * @param array $headers - */ - private function captureRateLimit(array $headers): void - { - $limit = $headers['x-ratelimit-limit'] ?? null; - $remaining = $headers['x-ratelimit-remaining'] ?? null; - $reset = $headers['x-ratelimit-reset'] ?? null; - $this->lastRateLimit = [ - 'limit' => $limit, - 'remaining' => $remaining, - 'reset' => $reset, - ]; - } + if ($result->errno !== 0) { + $exception = CurlErrorClassifier::classify($result, $summary, $attempt); + $lastException = $exception; + if (RetryPolicy::shouldRetryTransport($result, $method, $idempotencyKey) && $attempt < $this->maxAttempts) { + $this->backoff->sleep($this->backoff->delayMs($attempt, null)); + continue; + } + throw $exception; + } - /** - * @param array $headers - */ - private function parseRetryAfter(array $headers): ?int - { - if (isset($headers['retry-after']) && is_numeric($headers['retry-after'])) { - return (int) $headers['retry-after']; - } - return null; - } + $this->rateLimit->record($result->headers); - private function isRetriableStatus(int $status): bool - { - return $status === 429 || $status === 502 || $status === 503 || $status === 504; - } + if ($result->status >= 200 && $result->status < 300) { + return [ + 'body' => $result->body, + 'headers' => $result->headers, + 'status' => $result->status, + ]; + } - private function shouldRetryTransport(CurlResult $result, string $method, ?string $idempotencyKey): bool - { - // why: docs/architecture/transport.md — never retry POST without idempotency key on read timeout. - $isReadTimeout = $result->errno === CURLE_OPERATION_TIMEOUTED; - $isMutating = in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true); - if ($isReadTimeout && $isMutating && $idempotencyKey === null) { - return false; - } - return CurlErrorClassifier::isRetriableErrno($result->errno); - } + if (RetryPolicy::isRetriableStatus($result->status) && $attempt < $this->maxAttempts) { + $delay = $this->backoff->delayMs($attempt, RetryPolicy::parseRetryAfter($result->headers)); + $this->backoff->sleep($delay); + continue; + } - private function backoffDelay(int $attempt, ?int $retryAfterSeconds): int - { - if ($retryAfterSeconds !== null) { - return min($this->maxDelayMs, $retryAfterSeconds * 1000); + throw ApiException::fromResponse( + $result->status, + $result->body, + $result->headers, + $summary, + $attempt, + ); } - $expo = $this->baseDelayMs * (2 ** ($attempt - 1)); - $capped = min($this->maxDelayMs, $expo); - // why: full jitter — distributes retries to avoid thundering herd. - return random_int(0, max(1, (int) $capped)); - } - private function sleep(int $milliseconds): void - { - if ($milliseconds <= 0) { - return; - } - if ($this->sleeper !== null) { - ($this->sleeper)($milliseconds); - return; + if ($lastException !== null) { + throw $lastException; } - usleep($milliseconds * 1000); - } - - private function cacheKey(string $method, string $url): string - { - return hash('sha256', $method . ' ' . $url . ' ' . substr($this->apiKey, -4)); + throw new \LogicException('Transport loop exited without a result; should be unreachable'); } /** - * @param array>|null $query - * @param array|null $body - * - * @return array + * @return array{limit: ?string, remaining: ?string, reset: ?string} */ - private function summarise(string $method, string $path, ?array $query, ?array $body): array + public function getLastRateLimit(): array { - return [ - 'method' => $method, - 'path' => $path, - 'query' => $query, - 'bodyShape' => $body !== null ? $this->describeBody($body) : null, - 'auth' => 'Bearer ' . $this->lastFour($this->apiKey), - ]; + return $this->rateLimit->lastRateLimit(); } - /** - * @param array $body - * @return array - */ - private function describeBody(array $body): array + public function getLastRequestId(): ?string { - return [ - 'keys' => count($body), - 'type' => array_is_list($body) ? 'list' : 'object', - ]; + return $this->rateLimit->lastRequestId(); } - private function lastFour(string $apiKey): string + public static function generateUuidV4(): string { - return strlen($apiKey) <= 4 ? '****' : substr($apiKey, -4); + return Uuid::v4(); } /** diff --git a/packages/php/src/V1/Accounts.php b/packages/php/src/V1/Accounts.php index 090adba..6a7202c 100644 --- a/packages/php/src/V1/Accounts.php +++ b/packages/php/src/V1/Accounts.php @@ -4,33 +4,68 @@ namespace Tesote\Sdk\V1; +use Tesote\Sdk\Models\Account; +use Tesote\Sdk\Models\AccountList; +use Tesote\Sdk\Models\TransactionList; use Tesote\Sdk\Transport; -/** - * v1 accounts: read-only listing and lookup. - * - * Other v2 mutations (sync) are not part of v1. - */ +/** v1 accounts: list, get, list-transactions. Read-only. */ final class Accounts { + private const LIST_TTL = 60; + private const SHOW_TTL = 300; + private const TXN_TTL = 60; + public function __construct(private readonly Transport $transport) { } /** - * @param array>|null $query - * @return array|null + * @param array{ + * page?: int, + * per_page?: int, + * include?: string, + * sort?: string, + * } $query */ - public function list(?array $query = null): ?array + public function list(array $query = []): AccountList + { + $body = $this->transport->request('GET', '/v1/accounts', $query, null, ['cacheTtl' => self::LIST_TTL]) ?? []; + return AccountList::fromArray($body); + } + + public function get(string $id): Account { - return $this->transport->request('GET', '/v1/accounts', $query); + $body = $this->transport->request( + 'GET', + '/v1/accounts/' . rawurlencode($id), + null, + null, + ['cacheTtl' => self::SHOW_TTL], + ) ?? []; + return Account::fromArray($body); } /** - * @return array|null + * @param array{ + * start_date?: string, + * end_date?: string, + * scope?: string, + * page?: int, + * per_page?: int, + * transactions_after_id?: string, + * transactions_before_id?: string, + * } $query */ - public function get(string $id): ?array + public function listTransactions(string $accountId, array $query = []): TransactionList { - return $this->transport->request('GET', '/v1/accounts/' . rawurlencode($id)); + $body = $this->transport->request( + 'GET', + '/v1/accounts/' . rawurlencode($accountId) . '/transactions', + $query, + null, + ['cacheTtl' => self::TXN_TTL], + ) ?? []; + return TransactionList::fromArray($body); } } diff --git a/packages/php/src/V1/Client.php b/packages/php/src/V1/Client.php index af68265..59bbd85 100644 --- a/packages/php/src/V1/Client.php +++ b/packages/php/src/V1/Client.php @@ -4,11 +4,10 @@ namespace Tesote\Sdk\V1; -use Tesote\Sdk\NotImplemented; use Tesote\Sdk\Transport; /** - * v1 client. Read-only foundation: accounts + transactions, plus status/whoami. + * v1 client. Read-only foundation: status, whoami, accounts, transactions. * * Constructor accepts the same shared config shape as V2 — see Transport. */ @@ -16,8 +15,8 @@ final class Client { public readonly Transport $transport; public readonly Accounts $accounts; - public readonly NotImplemented $transactions; - public readonly NotImplemented $status; + public readonly Transactions $transactions; + public readonly Status $status; /** * @param array $config See Transport::__construct. @@ -26,7 +25,7 @@ public function __construct(array $config) { $this->transport = $config['transport'] ?? new Transport($config); $this->accounts = new Accounts($this->transport); - $this->transactions = new NotImplemented('transactions'); - $this->status = new NotImplemented('status'); + $this->transactions = new Transactions($this->transport); + $this->status = new Status($this->transport); } } diff --git a/packages/php/src/V1/Status.php b/packages/php/src/V1/Status.php new file mode 100644 index 0000000..5ef9993 --- /dev/null +++ b/packages/php/src/V1/Status.php @@ -0,0 +1,29 @@ +transport->request('GET', '/status') ?? []; + return StatusInfo::fromArray($body); + } + + public function whoami(): WhoAmI + { + $body = $this->transport->request('GET', '/whoami') ?? []; + return WhoAmI::fromArray($body); + } +} diff --git a/packages/php/src/V1/Transactions.php b/packages/php/src/V1/Transactions.php new file mode 100644 index 0000000..87c5a74 --- /dev/null +++ b/packages/php/src/V1/Transactions.php @@ -0,0 +1,30 @@ +transport->request( + 'GET', + '/v1/transactions/' . rawurlencode($id), + null, + null, + ['cacheTtl' => self::SHOW_TTL], + ) ?? []; + return Transaction::fromArray($body); + } +} diff --git a/packages/php/src/V2/Accounts.php b/packages/php/src/V2/Accounts.php index 54e1b3e..3154a11 100644 --- a/packages/php/src/V2/Accounts.php +++ b/packages/php/src/V2/Accounts.php @@ -4,43 +4,166 @@ namespace Tesote\Sdk\V2; +use Tesote\Sdk\Models\Account; +use Tesote\Sdk\Models\AccountList; +use Tesote\Sdk\Models\ExportFile; +use Tesote\Sdk\Models\SyncResult; +use Tesote\Sdk\Models\SyncStarted; +use Tesote\Sdk\Models\TransactionList; use Tesote\Sdk\Transport; -/** v2 accounts: list, get, sync. */ +/** v2 accounts: list, get, sync, transactions index, transaction sync, export. */ final class Accounts { + private const LIST_TTL = 60; + private const SHOW_TTL = 300; + private const TXN_TTL = 60; + public function __construct(private readonly Transport $transport) { } /** - * @param array>|null $query - * @return array|null + * @param array{ + * page?: int, + * per_page?: int, + * include?: string, + * sort?: string, + * } $query + */ + public function list(array $query = []): AccountList + { + $body = $this->transport->request('GET', '/v2/accounts', $query, null, ['cacheTtl' => self::LIST_TTL]) ?? []; + return AccountList::fromArray($body); + } + + public function get(string $id): Account + { + $body = $this->transport->request( + 'GET', + '/v2/accounts/' . rawurlencode($id), + null, + null, + ['cacheTtl' => self::SHOW_TTL], + ) ?? []; + return Account::fromArray($body); + } + + public function sync(string $id, ?string $idempotencyKey = null): SyncStarted + { + $body = $this->transport->request( + 'POST', + '/v2/accounts/' . rawurlencode($id) . '/sync', + null, + [], + ['idempotencyKey' => $idempotencyKey], + ) ?? []; + return SyncStarted::fromArray($body); + } + + /** + * @param array{ + * start_date?: string, + * end_date?: string, + * scope?: string, + * page?: int, + * per_page?: int, + * transactions_after_id?: string, + * transactions_before_id?: string, + * transaction_date_after?: string, + * transaction_date_before?: string, + * created_after?: string, + * updated_after?: string, + * amount_min?: float|int|string, + * amount_max?: float|int|string, + * amount?: float|int|string, + * status?: string, + * category_id?: string, + * counterparty_id?: string, + * q?: string, + * type?: string, + * reference_code?: string, + * } $query */ - public function list(?array $query = null): ?array + public function listTransactions(string $accountId, array $query = []): TransactionList { - return $this->transport->request('GET', '/v2/accounts', $query); + $body = $this->transport->request( + 'GET', + '/v2/accounts/' . rawurlencode($accountId) . '/transactions', + $query, + null, + ['cacheTtl' => self::TXN_TTL], + ) ?? []; + return TransactionList::fromArray($body); } /** - * @return array|null + * Export the transactions for an account as CSV or JSON. + * + * @param array{ + * format?: 'csv'|'json', + * start_date?: string, + * end_date?: string, + * scope?: string, + * status?: string, + * category_id?: string, + * counterparty_id?: string, + * q?: string, + * type?: string, + * reference_code?: string, + * amount_min?: float|int|string, + * amount_max?: float|int|string, + * amount?: float|int|string, + * transaction_date_after?: string, + * transaction_date_before?: string, + * created_after?: string, + * updated_after?: string, + * } $query */ - public function get(string $id): ?array + public function exportTransactions(string $accountId, array $query = []): ExportFile { - return $this->transport->request('GET', '/v2/accounts/' . rawurlencode($id)); + $format = isset($query['format']) ? (string) $query['format'] : 'csv'; + $raw = $this->transport->requestRaw( + 'GET', + '/v2/accounts/' . rawurlencode($accountId) . '/transactions/export', + $query, + ); + return new ExportFile( + body: $raw['body'], + format: $format, + filename: self::parseFilename($raw['headers']['content-disposition'] ?? null), + ); } /** - * @return array|null + * @param array{ + * count?: int, + * cursor?: string|null, + * options?: array{include_running_balance?: bool}, + * } $body */ - public function sync(string $id, ?string $idempotencyKey = null): ?array + public function syncTransactions(string $accountId, array $body = [], ?string $idempotencyKey = null): SyncResult { - return $this->transport->request( + $payload = $body !== [] ? $body : (object) []; + $decoded = $this->transport->request( 'POST', - '/v2/accounts/' . rawurlencode($id) . '/sync', + '/v2/accounts/' . rawurlencode($accountId) . '/transactions/sync', null, - [], + (array) $payload, ['idempotencyKey' => $idempotencyKey], - ); + ) ?? []; + return SyncResult::fromArray($decoded); + } + + private static function parseFilename(?string $disposition): ?string + { + if ($disposition === null) { + return null; + } + // why: matches both filename="name.csv" and filename=name.csv (RFC 6266 minimum). + if (preg_match('/filename\*?=(?:"([^"]+)"|([^;\s]+))/i', $disposition, $m) === 1) { + return $m[1] !== '' ? $m[1] : ($m[2] ?? null); + } + return null; } } diff --git a/packages/php/src/V2/Batches.php b/packages/php/src/V2/Batches.php new file mode 100644 index 0000000..0667654 --- /dev/null +++ b/packages/php/src/V2/Batches.php @@ -0,0 +1,79 @@ +> $orders + */ + public function create(string $accountId, array $orders, ?string $idempotencyKey = null): BatchCreated + { + $body = $this->transport->request( + 'POST', + '/v2/accounts/' . rawurlencode($accountId) . '/batches', + null, + ['orders' => $orders], + ['idempotencyKey' => $idempotencyKey], + ) ?? []; + return BatchCreated::fromArray($body); + } + + public function get(string $accountId, string $batchId): BatchSummary + { + $body = $this->transport->request( + 'GET', + '/v2/accounts/' . rawurlencode($accountId) . '/batches/' . rawurlencode($batchId), + ) ?? []; + return BatchSummary::fromArray($body); + } + + public function approve(string $accountId, string $batchId, ?string $idempotencyKey = null): BatchActionResult + { + $body = $this->transport->request( + 'POST', + '/v2/accounts/' . rawurlencode($accountId) . '/batches/' . rawurlencode($batchId) . '/approve', + null, + [], + ['idempotencyKey' => $idempotencyKey], + ) ?? []; + return BatchActionResult::fromArray($body); + } + + public function submit(string $accountId, string $batchId, ?string $token = null, ?string $idempotencyKey = null): BatchActionResult + { + $payload = $token !== null ? ['token' => $token] : (object) []; + $body = $this->transport->request( + 'POST', + '/v2/accounts/' . rawurlencode($accountId) . '/batches/' . rawurlencode($batchId) . '/submit', + null, + (array) $payload, + ['idempotencyKey' => $idempotencyKey], + ) ?? []; + return BatchActionResult::fromArray($body); + } + + public function cancel(string $accountId, string $batchId, ?string $idempotencyKey = null): BatchActionResult + { + $body = $this->transport->request( + 'POST', + '/v2/accounts/' . rawurlencode($accountId) . '/batches/' . rawurlencode($batchId) . '/cancel', + null, + [], + ['idempotencyKey' => $idempotencyKey], + ) ?? []; + return BatchActionResult::fromArray($body); + } +} diff --git a/packages/php/src/V2/Client.php b/packages/php/src/V2/Client.php index d459ed8..a0f1bf9 100644 --- a/packages/php/src/V2/Client.php +++ b/packages/php/src/V2/Client.php @@ -4,7 +4,6 @@ namespace Tesote\Sdk\V2; -use Tesote\Sdk\NotImplemented; use Tesote\Sdk\Transport; /** @@ -15,12 +14,12 @@ final class Client { public readonly Transport $transport; public readonly Accounts $accounts; - public readonly NotImplemented $transactions; - public readonly NotImplemented $syncSessions; - public readonly NotImplemented $transactionOrders; - public readonly NotImplemented $batches; - public readonly NotImplemented $paymentMethods; - public readonly NotImplemented $status; + public readonly Transactions $transactions; + public readonly SyncSessions $syncSessions; + public readonly TransactionOrders $transactionOrders; + public readonly Batches $batches; + public readonly PaymentMethods $paymentMethods; + public readonly Status $status; /** * @param array $config See Transport::__construct. @@ -29,11 +28,11 @@ public function __construct(array $config) { $this->transport = $config['transport'] ?? new Transport($config); $this->accounts = new Accounts($this->transport); - $this->transactions = new NotImplemented('transactions'); - $this->syncSessions = new NotImplemented('sync_sessions'); - $this->transactionOrders = new NotImplemented('transaction_orders'); - $this->batches = new NotImplemented('batches'); - $this->paymentMethods = new NotImplemented('payment_methods'); - $this->status = new NotImplemented('status'); + $this->transactions = new Transactions($this->transport); + $this->syncSessions = new SyncSessions($this->transport); + $this->transactionOrders = new TransactionOrders($this->transport); + $this->batches = new Batches($this->transport); + $this->paymentMethods = new PaymentMethods($this->transport); + $this->status = new Status($this->transport); } } diff --git a/packages/php/src/V2/PaymentMethods.php b/packages/php/src/V2/PaymentMethods.php new file mode 100644 index 0000000..ad0f656 --- /dev/null +++ b/packages/php/src/V2/PaymentMethods.php @@ -0,0 +1,87 @@ +transport->request('GET', '/v2/payment_methods', $query) ?? []; + return PaymentMethodList::fromArray($body); + } + + public function get(string $id): PaymentMethod + { + $body = $this->transport->request('GET', '/v2/payment_methods/' . rawurlencode($id)) ?? []; + return PaymentMethod::fromArray($body); + } + + /** + * @param array{ + * method_type: string, + * currency: string, + * label?: string|null, + * counterparty_id?: string|null, + * counterparty?: array, + * details: array, + * } $paymentMethod + */ + public function create(array $paymentMethod, ?string $idempotencyKey = null): PaymentMethod + { + $body = $this->transport->request( + 'POST', + '/v2/payment_methods', + null, + ['payment_method' => $paymentMethod], + ['idempotencyKey' => $idempotencyKey], + ) ?? []; + return PaymentMethod::fromArray($body); + } + + /** + * @param array $changes + */ + public function update(string $id, array $changes, ?string $idempotencyKey = null): PaymentMethod + { + $body = $this->transport->request( + 'PATCH', + '/v2/payment_methods/' . rawurlencode($id), + null, + ['payment_method' => $changes], + ['idempotencyKey' => $idempotencyKey], + ) ?? []; + return PaymentMethod::fromArray($body); + } + + public function delete(string $id, ?string $idempotencyKey = null): void + { + $this->transport->request( + 'DELETE', + '/v2/payment_methods/' . rawurlencode($id), + null, + null, + ['idempotencyKey' => $idempotencyKey], + ); + } +} diff --git a/packages/php/src/V2/Status.php b/packages/php/src/V2/Status.php new file mode 100644 index 0000000..f1b50c1 --- /dev/null +++ b/packages/php/src/V2/Status.php @@ -0,0 +1,29 @@ +transport->request('GET', '/v2/status') ?? []; + return StatusInfo::fromArray($body); + } + + public function whoami(): WhoAmI + { + $body = $this->transport->request('GET', '/v2/whoami') ?? []; + return WhoAmI::fromArray($body); + } +} diff --git a/packages/php/src/V2/SyncSessions.php b/packages/php/src/V2/SyncSessions.php new file mode 100644 index 0000000..08f1b0f --- /dev/null +++ b/packages/php/src/V2/SyncSessions.php @@ -0,0 +1,43 @@ +transport->request( + 'GET', + '/v2/accounts/' . rawurlencode($accountId) . '/sync_sessions', + $query, + ) ?? []; + return SyncSessionList::fromArray($body); + } + + public function get(string $accountId, string $sessionId): SyncSession + { + $body = $this->transport->request( + 'GET', + '/v2/accounts/' . rawurlencode($accountId) . '/sync_sessions/' . rawurlencode($sessionId), + ) ?? []; + return SyncSession::fromArray($body); + } +} diff --git a/packages/php/src/V2/TransactionOrders.php b/packages/php/src/V2/TransactionOrders.php new file mode 100644 index 0000000..e20d837 --- /dev/null +++ b/packages/php/src/V2/TransactionOrders.php @@ -0,0 +1,95 @@ +transport->request( + 'GET', + '/v2/accounts/' . rawurlencode($accountId) . '/transaction_orders', + $query, + ) ?? []; + return TransactionOrderList::fromArray($body); + } + + public function get(string $accountId, string $orderId): TransactionOrder + { + $body = $this->transport->request( + 'GET', + '/v2/accounts/' . rawurlencode($accountId) . '/transaction_orders/' . rawurlencode($orderId), + ) ?? []; + return TransactionOrder::fromArray($body); + } + + /** + * @param array{ + * destination_payment_method_id?: string|null, + * beneficiary?: array, + * amount: string, + * currency: string, + * description: string, + * scheduled_for?: string|null, + * idempotency_key?: string|null, + * metadata?: array, + * } $order + */ + public function create(string $accountId, array $order, ?string $idempotencyKey = null): TransactionOrder + { + $body = $this->transport->request( + 'POST', + '/v2/accounts/' . rawurlencode($accountId) . '/transaction_orders', + null, + ['transaction_order' => $order], + ['idempotencyKey' => $idempotencyKey], + ) ?? []; + return TransactionOrder::fromArray($body); + } + + public function submit(string $accountId, string $orderId, ?string $token = null, ?string $idempotencyKey = null): TransactionOrder + { + $payload = $token !== null ? ['token' => $token] : (object) []; + $body = $this->transport->request( + 'POST', + '/v2/accounts/' . rawurlencode($accountId) . '/transaction_orders/' . rawurlencode($orderId) . '/submit', + null, + (array) $payload, + ['idempotencyKey' => $idempotencyKey], + ) ?? []; + return TransactionOrder::fromArray($body); + } + + public function cancel(string $accountId, string $orderId, ?string $idempotencyKey = null): TransactionOrder + { + $body = $this->transport->request( + 'POST', + '/v2/accounts/' . rawurlencode($accountId) . '/transaction_orders/' . rawurlencode($orderId) . '/cancel', + null, + [], + ['idempotencyKey' => $idempotencyKey], + ) ?? []; + return TransactionOrder::fromArray($body); + } +} diff --git a/packages/php/src/V2/Transactions.php b/packages/php/src/V2/Transactions.php new file mode 100644 index 0000000..f643d9f --- /dev/null +++ b/packages/php/src/V2/Transactions.php @@ -0,0 +1,100 @@ +transport->request( + 'GET', + '/v2/transactions/' . rawurlencode($id), + null, + null, + ['cacheTtl' => self::SHOW_TTL], + ) ?? []; + return Transaction::fromArray($body); + } + + /** + * Legacy non-nested sync — POST /v2/transactions/sync. Prefer V2\Accounts::syncTransactions. + * + * @param array $body + */ + public function sync(array $body = [], ?string $idempotencyKey = null): SyncResult + { + $payload = $body !== [] ? $body : (object) []; + $decoded = $this->transport->request( + 'POST', + '/v2/transactions/sync', + null, + (array) $payload, + ['idempotencyKey' => $idempotencyKey], + ) ?? []; + return SyncResult::fromArray($decoded); + } + + /** + * @param array{ + * account_ids: list, + * page?: int, + * per_page?: int, + * limit?: int, + * offset?: int, + * } $body + */ + public function bulk(array $body, ?string $idempotencyKey = null): BulkResult + { + $decoded = $this->transport->request( + 'POST', + '/v2/transactions/bulk', + null, + $body, + ['idempotencyKey' => $idempotencyKey], + ) ?? []; + return BulkResult::fromArray($decoded); + } + + /** + * @param array{ + * q: string, + * account_id?: string, + * limit?: int, + * offset?: int, + * start_date?: string, + * end_date?: string, + * status?: string, + * category_id?: string, + * counterparty_id?: string, + * type?: string, + * reference_code?: string, + * amount_min?: float|int|string, + * amount_max?: float|int|string, + * amount?: float|int|string, + * transaction_date_after?: string, + * transaction_date_before?: string, + * created_after?: string, + * updated_after?: string, + * } $query + */ + public function search(array $query): TransactionSearchResult + { + $body = $this->transport->request('GET', '/v2/transactions/search', $query) ?? []; + return TransactionSearchResult::fromArray($body); + } +} diff --git a/packages/php/tests/Support/TestCaseBase.php b/packages/php/tests/Support/TestCaseBase.php new file mode 100644 index 0000000..ec2f978 --- /dev/null +++ b/packages/php/tests/Support/TestCaseBase.php @@ -0,0 +1,129 @@ + */ + protected array $sleeps = []; + + protected function setUp(): void + { + $this->curl = new FakeCurl(); + $this->sleeps = []; + } + + /** + * @param array $overrides + */ + protected function makeTransport(array $overrides = []): Transport + { + $config = array_merge([ + 'apiKey' => 'secret-key-1234', + 'curl' => $this->curl, + 'sleeper' => function (int $ms): void { + $this->sleeps[] = $ms; + }, + ], $overrides); + return new Transport($config); + } + + /** + * @param array $body + * @param array $headers + */ + protected function enqueueOk(array $body, int $status = 200, array $headers = []): void + { + $payload = $body === [] ? '{}' : json_encode($body, JSON_THROW_ON_ERROR); + $this->curl->enqueue(new CurlResult( + status: $status, + body: $payload, + headers: array_merge(['x-request-id' => 'req-ok'], $headers), + errno: 0, + errorMessage: '', + )); + } + + /** + * @param array $headers + */ + protected function enqueueRaw(string $body, int $status = 200, array $headers = []): void + { + $this->curl->enqueue(new CurlResult( + status: $status, + body: $body, + headers: array_merge(['x-request-id' => 'req-ok'], $headers), + errno: 0, + errorMessage: '', + )); + } + + /** + * @param array $envelope + * @param array $headers + */ + protected function enqueueError(int $status, array $envelope, array $headers = []): void + { + $this->curl->enqueue(new CurlResult( + status: $status, + body: json_encode($envelope, JSON_THROW_ON_ERROR), + headers: array_merge(['x-request-id' => 'req-err'], $headers), + errno: 0, + errorMessage: '', + )); + } + + protected function lastUrl(): string + { + $call = end($this->curl->calls); + if ($call === false) { + self::fail('No requests captured.'); + } + return (string) $call['options'][CURLOPT_URL]; + } + + protected function lastMethod(): string + { + $call = end($this->curl->calls); + if ($call === false) { + self::fail('No requests captured.'); + } + return (string) $call['options'][CURLOPT_CUSTOMREQUEST]; + } + + /** + * @return array + */ + protected function lastHeaders(): array + { + $call = end($this->curl->calls); + if ($call === false) { + self::fail('No requests captured.'); + } + return $call['headers']; + } + + protected function lastBody(): ?string + { + $call = end($this->curl->calls); + if ($call === false) { + self::fail('No requests captured.'); + } + return isset($call['options'][CURLOPT_POSTFIELDS]) ? (string) $call['options'][CURLOPT_POSTFIELDS] : null; + } +} diff --git a/packages/php/tests/V1/AccountsTest.php b/packages/php/tests/V1/AccountsTest.php new file mode 100644 index 0000000..a04e2b9 --- /dev/null +++ b/packages/php/tests/V1/AccountsTest.php @@ -0,0 +1,153 @@ +enqueueOk([ + 'total' => 1, + 'accounts' => [[ + 'id' => 'acct-1', + 'name' => 'Checking', + 'data' => [ + 'masked_account_number' => '****1234', + 'currency' => 'VES', + 'transactions_data_current_as_of' => '2026-04-28T00:00:00Z', + 'balance_data_current_as_of' => '2026-04-28T00:00:00Z', + 'custom_user_provided_identifier' => 'main', + 'balance_cents' => '12345', + ], + 'bank' => ['name' => 'Mercantil'], + 'legal_entity' => ['id' => 'le-1', 'legal_name' => 'ACME C.A.'], + 'tesote_created_at' => '2026-01-01T00:00:00Z', + 'tesote_updated_at' => '2026-04-01T00:00:00Z', + ]], + 'pagination' => [ + 'current_page' => 1, + 'per_page' => 50, + 'total_pages' => 1, + 'total_count' => 1, + ], + ]); + $client = new Client(['transport' => $this->makeTransport()]); + $list = $client->accounts->list(['page' => 1, 'per_page' => 50]); + + self::assertInstanceOf(AccountList::class, $list); + self::assertSame(1, $list->total); + self::assertSame('acct-1', $list->accounts[0]->id); + self::assertSame('Mercantil', $list->accounts[0]->bank->name); + self::assertSame('12345', $list->accounts[0]->data->balanceCents); + self::assertStringContainsString('/v1/accounts?page=1&per_page=50', $this->lastUrl()); + self::assertSame('GET', $this->lastMethod()); + } + + public function testGetReturnsAccount(): void + { + $this->enqueueOk([ + 'id' => 'acct-1', + 'name' => 'Savings', + 'data' => ['currency' => 'VES'], + 'bank' => ['name' => 'BNC'], + 'legal_entity' => null, + 'tesote_created_at' => '2026-01-01T00:00:00Z', + 'tesote_updated_at' => '2026-04-01T00:00:00Z', + ]); + $client = new Client(['transport' => $this->makeTransport()]); + $account = $client->accounts->get('acct-1'); + + self::assertInstanceOf(Account::class, $account); + self::assertNull($account->legalEntity); + self::assertStringContainsString('/v1/accounts/acct-1', $this->lastUrl()); + } + + public function testGetMapsAccountNotFound(): void + { + $this->enqueueError(404, ['error' => 'gone', 'error_code' => 'ACCOUNT_NOT_FOUND']); + $client = new Client(['transport' => $this->makeTransport()]); + $this->expectException(AccountNotFoundException::class); + $client->accounts->get('missing'); + } + + public function testListMapsUnauthorized(): void + { + $this->enqueueError(401, ['error' => 'no key', 'error_code' => 'UNAUTHORIZED']); + $client = new Client(['transport' => $this->makeTransport()]); + $this->expectException(UnauthorizedException::class); + $client->accounts->list(); + } + + public function testListTransactionsCursorPagination(): void + { + $this->enqueueOk([ + 'total' => 2, + 'transactions' => [ + [ + 'id' => 'txn-1', + 'status' => 'posted', + 'data' => [ + 'amount_cents' => 1000, + 'currency' => 'VES', + 'description' => 'Coffee', + 'transaction_date' => '2026-04-20', + ], + 'tesote_imported_at' => '2026-04-20T01:00:00Z', + 'tesote_updated_at' => '2026-04-20T01:00:00Z', + 'transaction_categories' => [], + 'counterparty' => ['name' => 'Cafe'], + ], + [ + 'id' => 'txn-2', + 'status' => 'posted', + 'data' => [ + 'amount_cents' => 500, + 'currency' => 'VES', + 'description' => 'Bread', + 'transaction_date' => '2026-04-21', + ], + 'tesote_imported_at' => '2026-04-21T01:00:00Z', + 'tesote_updated_at' => '2026-04-21T01:00:00Z', + 'transaction_categories' => [], + 'counterparty' => null, + ], + ], + 'pagination' => [ + 'has_more' => true, + 'per_page' => 50, + 'after_id' => 'txn-2', + 'before_id' => 'txn-1', + ], + ]); + $client = new Client(['transport' => $this->makeTransport()]); + $list = $client->accounts->listTransactions('acct-1', [ + 'transactions_after_id' => 'cursor-prev', + 'per_page' => 50, + ]); + + self::assertInstanceOf(TransactionList::class, $list); + self::assertCount(2, $list->transactions); + self::assertTrue($list->pagination->hasMore); + self::assertSame('txn-2', $list->pagination->afterId); + self::assertStringContainsString('transactions_after_id=cursor-prev', $this->lastUrl()); + } + + public function testListTransactionsMapsInvalidDateRange(): void + { + $this->enqueueError(422, ['error' => 'bad range', 'error_code' => 'INVALID_DATE_RANGE']); + $client = new Client(['transport' => $this->makeTransport()]); + $this->expectException(InvalidDateRangeException::class); + $client->accounts->listTransactions('acct-1', ['start_date' => '2099-01-01', 'end_date' => '2025-01-01']); + } +} diff --git a/packages/php/tests/V1/StatusTest.php b/packages/php/tests/V1/StatusTest.php new file mode 100644 index 0000000..41a2a73 --- /dev/null +++ b/packages/php/tests/V1/StatusTest.php @@ -0,0 +1,38 @@ +enqueueOk(['status' => 'ok', 'authenticated' => false]); + $client = new Client(['transport' => $this->makeTransport()]); + $info = $client->status->check(); + + self::assertInstanceOf(StatusInfo::class, $info); + self::assertSame('ok', $info->status); + self::assertFalse($info->authenticated); + self::assertStringContainsString('/status', $this->lastUrl()); + } + + public function testWhoamiReturnsModel(): void + { + $this->enqueueOk([ + 'client' => ['id' => 'ws-1', 'name' => 'ACME', 'type' => 'workspace'], + ]); + $client = new Client(['transport' => $this->makeTransport()]); + $who = $client->status->whoami(); + + self::assertInstanceOf(WhoAmI::class, $who); + self::assertSame('workspace', $who->type); + self::assertStringContainsString('/whoami', $this->lastUrl()); + } +} diff --git a/packages/php/tests/V1/TransactionsTest.php b/packages/php/tests/V1/TransactionsTest.php new file mode 100644 index 0000000..31f842b --- /dev/null +++ b/packages/php/tests/V1/TransactionsTest.php @@ -0,0 +1,52 @@ +enqueueOk([ + 'id' => 'txn-1', + 'status' => 'posted', + 'data' => [ + 'amount_cents' => 12500, + 'currency' => 'VES', + 'description' => 'Salary', + 'transaction_date' => '2026-04-15', + 'created_at' => '2026-04-15T08:00:00Z', + 'note' => null, + 'running_balance_cents' => 50000, + ], + 'tesote_imported_at' => '2026-04-15T08:00:00Z', + 'tesote_updated_at' => '2026-04-15T08:00:00Z', + 'transaction_categories' => [ + ['name' => 'Income', 'external_category_code' => 'INC', 'created_at' => 't', 'updated_at' => 't'], + ], + 'counterparty' => ['name' => 'Employer'], + ]); + $client = new Client(['transport' => $this->makeTransport()]); + $txn = $client->transactions->get('txn-1'); + + self::assertInstanceOf(Transaction::class, $txn); + self::assertSame(12500, $txn->data->amountCents); + self::assertSame(50000, $txn->data->runningBalanceCents); + self::assertCount(1, $txn->transactionCategories); + self::assertSame('Income', $txn->transactionCategories[0]->name); + } + + public function testGetMapsTransactionNotFound(): void + { + $this->enqueueError(404, ['error' => 'no', 'error_code' => 'TRANSACTION_NOT_FOUND']); + $client = new Client(['transport' => $this->makeTransport()]); + $this->expectException(TransactionNotFoundException::class); + $client->transactions->get('missing'); + } +} diff --git a/packages/php/tests/V2/AccountsTest.php b/packages/php/tests/V2/AccountsTest.php new file mode 100644 index 0000000..1d45e17 --- /dev/null +++ b/packages/php/tests/V2/AccountsTest.php @@ -0,0 +1,182 @@ +enqueueOk([ + 'total' => 0, + 'accounts' => [], + 'pagination' => ['current_page' => 1, 'per_page' => 50, 'total_pages' => 0, 'total_count' => 0], + ]); + $client = new Client(['transport' => $this->makeTransport()]); + $list = $client->accounts->list(['per_page' => 50]); + + self::assertInstanceOf(AccountList::class, $list); + self::assertSame(0, $list->total); + self::assertStringContainsString('/v2/accounts?per_page=50', $this->lastUrl()); + } + + public function testSyncReturnsSyncStartedAndSendsIdempotencyKey(): void + { + $this->enqueueOk([ + 'message' => 'Sync started', + 'sync_session_id' => 'ss-1', + 'status' => 'pending', + 'started_at' => '2026-04-28T10:00:00Z', + ], 202); + $client = new Client(['transport' => $this->makeTransport()]); + $started = $client->accounts->sync('acct-1', 'caller-key-1'); + + self::assertInstanceOf(SyncStarted::class, $started); + self::assertSame('ss-1', $started->syncSessionId); + self::assertSame('caller-key-1', $this->lastHeaders()['Idempotency-Key']); + self::assertSame('POST', $this->lastMethod()); + self::assertStringEndsWith('/v2/accounts/acct-1/sync', $this->lastUrl()); + } + + public function testSyncMapsAllSyncErrorCodes(): void + { + $cases = [ + ['ACCOUNT_NOT_FOUND', 404, AccountNotFoundException::class], + ['BANK_CONNECTION_NOT_FOUND', 404, BankConnectionNotFoundException::class], + ['SYNC_IN_PROGRESS', 409, SyncInProgressException::class], + ['SYNC_RATE_LIMIT_EXCEEDED', 429, SyncRateLimitExceededException::class], + ['BANK_UNDER_MAINTENANCE', 503, BankUnderMaintenanceException::class], + ]; + foreach ($cases as [$code, $status, $class]) { + $this->setUp(); + // why: 429/503 retry, so we need 3 entries to surface the exception within maxAttempts=3. + $needRetries = in_array($code, ['SYNC_RATE_LIMIT_EXCEEDED', 'BANK_UNDER_MAINTENANCE'], true); + $count = $needRetries ? 3 : 1; + for ($i = 0; $i < $count; $i++) { + $this->enqueueError($status, ['error' => 'x', 'error_code' => $code]); + } + $client = new Client(['transport' => $this->makeTransport()]); + try { + $client->accounts->sync('acct-1'); + self::fail("expected $class for $code"); + } catch (\Throwable $e) { + self::assertInstanceOf($class, $e, "wrong class for $code"); + } + } + } + + public function testSyncTransactionsCursorAndCountErrors(): void + { + $this->enqueueError(422, ['error' => 'bad', 'error_code' => 'INVALID_CURSOR']); + $client = new Client(['transport' => $this->makeTransport()]); + try { + $client->accounts->syncTransactions('acct-1', ['cursor' => 'broken']); + self::fail('expected InvalidCursorException'); + } catch (InvalidCursorException $e) { + self::assertSame('INVALID_CURSOR', $e->errorCode); + } + + $this->setUp(); + $this->enqueueError(422, ['error' => 'too many', 'error_code' => 'INVALID_COUNT']); + $client = new Client(['transport' => $this->makeTransport()]); + try { + $client->accounts->syncTransactions('acct-1', ['count' => 9999]); + self::fail('expected InvalidCountException'); + } catch (InvalidCountException $e) { + self::assertSame('INVALID_COUNT', $e->errorCode); + } + + $this->setUp(); + $this->enqueueError(403, ['error' => 'pre-cutoff', 'error_code' => 'HISTORY_SYNC_FORBIDDEN']); + $client = new Client(['transport' => $this->makeTransport()]); + try { + $client->accounts->syncTransactions('acct-1', ['cursor' => 'old']); + self::fail('expected HistorySyncForbiddenException'); + } catch (HistorySyncForbiddenException $e) { + self::assertSame('HISTORY_SYNC_FORBIDDEN', $e->errorCode); + } + } + + public function testSyncTransactionsHappyPath(): void + { + $this->enqueueOk([ + 'added' => [[ + 'transaction_id' => 't-1', + 'account_id' => 'acct-1', + 'amount' => 99.5, + 'iso_currency_code' => 'VES', + 'date' => '2026-04-20', + 'name' => 'Coffee', + 'pending' => false, + 'category' => ['food'], + ]], + 'modified' => [], + 'removed' => [['transaction_id' => 't-x', 'account_id' => 'acct-1']], + 'next_cursor' => 'cursor-next', + 'has_more' => false, + ]); + $client = new Client(['transport' => $this->makeTransport()]); + $result = $client->accounts->syncTransactions( + 'acct-1', + ['count' => 100, 'cursor' => 'now', 'options' => ['include_running_balance' => true]], + 'idem-1', + ); + + self::assertInstanceOf(SyncResult::class, $result); + self::assertCount(1, $result->added); + self::assertSame('t-1', $result->added[0]->transactionId); + self::assertSame('cursor-next', $result->nextCursor); + self::assertCount(1, $result->removed); + self::assertSame('idem-1', $this->lastHeaders()['Idempotency-Key']); + + $body = json_decode($this->lastBody() ?? '', true); + self::assertSame(100, $body['count']); + self::assertSame('now', $body['cursor']); + } + + public function testExportTransactionsReturnsRawCsv(): void + { + $csv = "Transaction ID,Date,Description\nt-1,2026-04-20,Coffee\n"; + $this->enqueueRaw($csv, 200, [ + 'content-disposition' => 'attachment; filename="transactions_acct-1_2026-04-28.csv"', + 'content-type' => 'text/csv', + ]); + $client = new Client(['transport' => $this->makeTransport()]); + $file = $client->accounts->exportTransactions('acct-1', ['format' => 'csv', 'start_date' => '2026-04-01']); + + self::assertInstanceOf(ExportFile::class, $file); + self::assertSame('csv', $file->format); + self::assertSame('transactions_acct-1_2026-04-28.csv', $file->filename); + self::assertSame($csv, $file->body); + self::assertStringContainsString('format=csv', $this->lastUrl()); + } + + public function testRequest415WhenContentTypeMissing(): void + { + // why: emulate the server's 415 response when Content-Type is missing on a mutation. + $this->enqueueError(415, ['error' => 'need json', 'error_code' => 'UNSUPPORTED_MEDIA_TYPE']); + $client = new Client(['transport' => $this->makeTransport()]); + try { + $client->accounts->syncTransactions('acct-1', ['count' => 50]); + self::fail('expected ApiException'); + } catch (\Tesote\Sdk\Errors\ApiException $e) { + self::assertSame(415, $e->httpStatus); + } + } +} diff --git a/packages/php/tests/V2/BatchesTest.php b/packages/php/tests/V2/BatchesTest.php new file mode 100644 index 0000000..9398e68 --- /dev/null +++ b/packages/php/tests/V2/BatchesTest.php @@ -0,0 +1,98 @@ +enqueueOk(['batch_id' => 'b-1', 'orders' => [], 'errors' => []], 201); + $client = new Client(['transport' => $this->makeTransport()]); + $batch = $client->batches->create('acct-1', [ + ['amount' => '500.00', 'currency' => 'VES', 'description' => 'A'], + ['amount' => '500.00', 'currency' => 'VES', 'description' => 'B'], + ], 'idem-batch-1'); + + self::assertInstanceOf(BatchCreated::class, $batch); + self::assertSame('b-1', $batch->batchId); + self::assertSame('idem-batch-1', $this->lastHeaders()['Idempotency-Key']); + $body = json_decode($this->lastBody() ?? '', true); + self::assertCount(2, $body['orders']); + } + + public function testCreateMapsBatchValidationError(): void + { + $this->enqueueError(400, ['error' => 'bad batch', 'error_code' => 'BATCH_VALIDATION_ERROR']); + $client = new Client(['transport' => $this->makeTransport()]); + $this->expectException(BatchValidationException::class); + $client->batches->create('acct-1', []); + } + + public function testGetSummary(): void + { + $this->enqueueOk([ + 'batch_id' => 'b-1', + 'total_orders' => 2, + 'total_amount_cents' => 100000, + 'amount_currency' => 'VES', + 'statuses' => ['draft' => 2], + 'batch_status' => 'draft', + 'created_at' => '2026-04-28T10:00:00Z', + 'orders' => [], + ]); + $client = new Client(['transport' => $this->makeTransport()]); + $summary = $client->batches->get('acct-1', 'b-1'); + + self::assertInstanceOf(BatchSummary::class, $summary); + self::assertSame(['draft' => 2], $summary->statuses); + } + + public function testGetMapsNotFound(): void + { + $this->enqueueError(404, ['error' => 'gone', 'error_code' => 'BATCH_NOT_FOUND']); + $client = new Client(['transport' => $this->makeTransport()]); + $this->expectException(BatchNotFoundException::class); + $client->batches->get('acct-1', 'missing'); + } + + public function testApproveMutates(): void + { + $this->enqueueOk(['approved' => 5, 'failed' => 0]); + $client = new Client(['transport' => $this->makeTransport()]); + $result = $client->batches->approve('acct-1', 'b-1'); + + self::assertInstanceOf(BatchActionResult::class, $result); + self::assertSame(5, $result->approved); + self::assertNotEmpty($this->lastHeaders()['Idempotency-Key']); + self::assertStringEndsWith('/approve', $this->lastUrl()); + } + + public function testSubmitWithToken(): void + { + $this->enqueueOk(['enqueued' => 5, 'failed' => 0]); + $client = new Client(['transport' => $this->makeTransport()]); + $client->batches->submit('acct-1', 'b-1', 'OTP-1'); + + $body = json_decode($this->lastBody() ?? '', true); + self::assertSame('OTP-1', $body['token']); + } + + public function testCancelMapsInvalidOrderState(): void + { + $this->enqueueError(409, ['error' => 'cant', 'error_code' => 'INVALID_ORDER_STATE']); + $client = new Client(['transport' => $this->makeTransport()]); + $this->expectException(InvalidOrderStateException::class); + $client->batches->cancel('acct-1', 'b-1'); + } +} diff --git a/packages/php/tests/V2/PaymentMethodsTest.php b/packages/php/tests/V2/PaymentMethodsTest.php new file mode 100644 index 0000000..cd77114 --- /dev/null +++ b/packages/php/tests/V2/PaymentMethodsTest.php @@ -0,0 +1,127 @@ + + */ + private static function fixture(): array + { + return [ + 'id' => 'pm-1', + 'method_type' => 'bank_account', + 'currency' => 'VES', + 'label' => 'Main', + 'details' => ['bank_code' => '0102', 'account_number' => '****1234', 'holder_name' => 'ACME'], + 'verified' => true, + 'verified_at' => '2026-04-28T00:00:00Z', + 'last_used_at' => null, + 'counterparty' => ['id' => 'cp-1', 'name' => 'Vendor'], + 'tesote_account' => null, + 'created_at' => '2026-01-01T00:00:00Z', + 'updated_at' => '2026-04-01T00:00:00Z', + ]; + } + + public function testList(): void + { + $this->enqueueOk([ + 'items' => [self::fixture()], + 'has_more' => false, + 'limit' => 50, + 'offset' => 0, + ]); + $client = new Client(['transport' => $this->makeTransport()]); + $list = $client->paymentMethods->list(['method_type' => 'bank_account', 'verified' => 'true']); + + self::assertInstanceOf(PaymentMethodList::class, $list); + self::assertSame('pm-1', $list->items[0]->id); + self::assertStringContainsString('method_type=bank_account', $this->lastUrl()); + self::assertStringContainsString('verified=true', $this->lastUrl()); + } + + public function testGet(): void + { + $this->enqueueOk(self::fixture()); + $client = new Client(['transport' => $this->makeTransport()]); + $pm = $client->paymentMethods->get('pm-1'); + + self::assertInstanceOf(PaymentMethod::class, $pm); + self::assertTrue($pm->verified); + self::assertSame('Vendor', $pm->counterparty?->name); + } + + public function testGetMapsNotFound(): void + { + $this->enqueueError(404, ['error' => 'gone', 'error_code' => 'PAYMENT_METHOD_NOT_FOUND']); + $client = new Client(['transport' => $this->makeTransport()]); + $this->expectException(PaymentMethodNotFoundException::class); + $client->paymentMethods->get('missing'); + } + + public function testCreateWrapsBody(): void + { + $this->enqueueOk(self::fixture(), 201); + $client = new Client(['transport' => $this->makeTransport()]); + $pm = $client->paymentMethods->create([ + 'method_type' => 'bank_account', + 'currency' => 'VES', + 'label' => 'Main', + 'details' => ['bank_code' => '0102', 'account_number' => '...', 'holder_name' => 'ACME'], + ], 'idem-pm-1'); + + self::assertInstanceOf(PaymentMethod::class, $pm); + $body = json_decode($this->lastBody() ?? '', true); + self::assertSame('bank_account', $body['payment_method']['method_type']); + self::assertSame('idem-pm-1', $this->lastHeaders()['Idempotency-Key']); + } + + public function testUpdatePatchesPartial(): void + { + $this->enqueueOk(self::fixture()); + $client = new Client(['transport' => $this->makeTransport()]); + $client->paymentMethods->update('pm-1', ['label' => 'Renamed']); + + self::assertSame('PATCH', $this->lastMethod()); + $body = json_decode($this->lastBody() ?? '', true); + self::assertSame('Renamed', $body['payment_method']['label']); + } + + public function testUpdateMapsValidation(): void + { + $this->enqueueError(400, ['error' => 'bad', 'error_code' => 'VALIDATION_ERROR']); + $client = new Client(['transport' => $this->makeTransport()]); + $this->expectException(ValidationException::class); + $client->paymentMethods->update('pm-1', ['label' => '']); + } + + public function testDeleteReturnsVoidOn204(): void + { + $this->curl->enqueue(new CurlResult(204, '', ['x-request-id' => 'req-x'], 0, '')); + $client = new Client(['transport' => $this->makeTransport()]); + $client->paymentMethods->delete('pm-1', 'idem-del-1'); + + self::assertSame('DELETE', $this->lastMethod()); + self::assertSame('idem-del-1', $this->lastHeaders()['Idempotency-Key']); + } + + public function testDeleteMapsValidationWhenInUse(): void + { + $this->enqueueError(409, ['error' => 'in use', 'error_code' => 'VALIDATION_ERROR']); + $client = new Client(['transport' => $this->makeTransport()]); + $this->expectException(ValidationException::class); + $client->paymentMethods->delete('pm-1'); + } +} diff --git a/packages/php/tests/V2/StatusTest.php b/packages/php/tests/V2/StatusTest.php new file mode 100644 index 0000000..0156178 --- /dev/null +++ b/packages/php/tests/V2/StatusTest.php @@ -0,0 +1,42 @@ +enqueueOk(['status' => 'ok', 'authenticated' => false]); + $client = new Client(['transport' => $this->makeTransport()]); + $info = $client->status->check(); + + self::assertInstanceOf(StatusInfo::class, $info); + self::assertStringContainsString('/v2/status', $this->lastUrl()); + } + + public function testWhoami(): void + { + $this->enqueueOk(['client' => ['id' => 'u-1', 'name' => 'User', 'type' => 'user']]); + $client = new Client(['transport' => $this->makeTransport()]); + $who = $client->status->whoami(); + + self::assertInstanceOf(WhoAmI::class, $who); + self::assertSame('user', $who->type); + } + + public function testWhoamiUnauthorized(): void + { + $this->enqueueError(401, ['error' => 'no', 'error_code' => 'UNAUTHORIZED']); + $client = new Client(['transport' => $this->makeTransport()]); + $this->expectException(UnauthorizedException::class); + $client->status->whoami(); + } +} diff --git a/packages/php/tests/V2/SyncSessionsTest.php b/packages/php/tests/V2/SyncSessionsTest.php new file mode 100644 index 0000000..64671cf --- /dev/null +++ b/packages/php/tests/V2/SyncSessionsTest.php @@ -0,0 +1,76 @@ +enqueueOk([ + 'sync_sessions' => [ + [ + 'id' => 'ss-1', + 'status' => 'completed', + 'started_at' => '2026-04-28T10:00:00Z', + 'completed_at' => '2026-04-28T10:00:30Z', + 'transactions_synced' => 5, + 'accounts_count' => 1, + 'error' => null, + 'performance' => [ + 'total_duration' => 30.5, + 'complexity_score' => 1.0, + 'sync_speed_score' => 0.5, + ], + ], + ], + 'limit' => 50, + 'offset' => 0, + 'has_more' => false, + ]); + $client = new Client(['transport' => $this->makeTransport()]); + $list = $client->syncSessions->listForAccount('acct-1', ['limit' => 50, 'status' => 'completed']); + + self::assertInstanceOf(SyncSessionList::class, $list); + self::assertCount(1, $list->syncSessions); + self::assertNotNull($list->syncSessions[0]->performance); + self::assertSame(30.5, $list->syncSessions[0]->performance['total_duration']); + self::assertStringContainsString('limit=50&status=completed', $this->lastUrl()); + } + + public function testGet(): void + { + $this->enqueueOk([ + 'id' => 'ss-1', + 'status' => 'failed', + 'started_at' => 't', + 'completed_at' => null, + 'transactions_synced' => 0, + 'accounts_count' => 1, + 'error' => ['type' => 'BankError', 'message' => 'down'], + 'performance' => null, + ]); + $client = new Client(['transport' => $this->makeTransport()]); + $session = $client->syncSessions->get('acct-1', 'ss-1'); + + self::assertInstanceOf(SyncSession::class, $session); + self::assertSame('failed', $session->status); + self::assertNotNull($session->error); + self::assertSame('BankError', $session->error['type']); + } + + public function testGetMapsNotFound(): void + { + $this->enqueueError(404, ['error' => 'gone', 'error_code' => 'SYNC_SESSION_NOT_FOUND']); + $client = new Client(['transport' => $this->makeTransport()]); + $this->expectException(SyncSessionNotFoundException::class); + $client->syncSessions->get('acct-1', 'missing'); + } +} diff --git a/packages/php/tests/V2/TransactionOrdersTest.php b/packages/php/tests/V2/TransactionOrdersTest.php new file mode 100644 index 0000000..0474973 --- /dev/null +++ b/packages/php/tests/V2/TransactionOrdersTest.php @@ -0,0 +1,134 @@ + + */ + private static function orderFixture(string $status = 'draft'): array + { + return [ + 'id' => 'ord-1', + 'status' => $status, + 'amount' => '1000.00', + 'currency' => 'VES', + 'description' => 'Rent', + 'reference' => null, + 'external_reference' => null, + 'idempotency_key' => null, + 'batch_id' => null, + 'scheduled_for' => null, + 'approved_at' => null, + 'submitted_at' => null, + 'completed_at' => null, + 'failed_at' => null, + 'cancelled_at' => null, + 'source_account' => ['id' => 'acct-1', 'name' => 'Main', 'payment_method_id' => 'pm-1'], + 'destination' => ['payment_method_id' => 'pm-2', 'counterparty_id' => 'cp-1', 'counterparty_name' => 'Landlord'], + 'fee' => null, + 'execution_strategy' => null, + 'tesote_transaction' => null, + 'latest_attempt' => null, + 'created_at' => '2026-04-28T10:00:00Z', + 'updated_at' => '2026-04-28T10:00:00Z', + ]; + } + + public function testListForAccount(): void + { + $this->enqueueOk([ + 'items' => [self::orderFixture()], + 'has_more' => false, + 'limit' => 50, + 'offset' => 0, + ]); + $client = new Client(['transport' => $this->makeTransport()]); + $list = $client->transactionOrders->listForAccount('acct-1', ['status' => 'draft', 'limit' => 50]); + + self::assertInstanceOf(TransactionOrderList::class, $list); + self::assertCount(1, $list->items); + self::assertSame('ord-1', $list->items[0]->id); + self::assertStringContainsString('status=draft', $this->lastUrl()); + } + + public function testGet(): void + { + $this->enqueueOk(self::orderFixture('processing')); + $client = new Client(['transport' => $this->makeTransport()]); + $order = $client->transactionOrders->get('acct-1', 'ord-1'); + + self::assertInstanceOf(TransactionOrder::class, $order); + self::assertSame('processing', $order->status); + } + + public function testGetMapsNotFound(): void + { + $this->enqueueError(404, ['error' => 'gone', 'error_code' => 'TRANSACTION_ORDER_NOT_FOUND']); + $client = new Client(['transport' => $this->makeTransport()]); + $this->expectException(TransactionOrderNotFoundException::class); + $client->transactionOrders->get('acct-1', 'missing'); + } + + public function testCreateWrapsBodyAndUsesIdempotencyKey(): void + { + $this->enqueueOk(self::orderFixture(), 201); + $client = new Client(['transport' => $this->makeTransport()]); + $order = $client->transactionOrders->create( + 'acct-1', + [ + 'destination_payment_method_id' => 'pm-2', + 'amount' => '1000.00', + 'currency' => 'VES', + 'description' => 'Rent', + ], + 'idem-create-1', + ); + + self::assertInstanceOf(TransactionOrder::class, $order); + self::assertSame('idem-create-1', $this->lastHeaders()['Idempotency-Key']); + $body = json_decode($this->lastBody() ?? '', true); + self::assertSame('1000.00', $body['transaction_order']['amount']); + self::assertSame('Rent', $body['transaction_order']['description']); + } + + public function testCreateMapsValidationError(): void + { + $this->enqueueError(400, ['error' => 'bad amount', 'error_code' => 'VALIDATION_ERROR']); + $client = new Client(['transport' => $this->makeTransport()]); + $this->expectException(ValidationException::class); + $client->transactionOrders->create('acct-1', [ + 'amount' => 'NaN', 'currency' => 'VES', 'description' => 'oops', + ]); + } + + public function testSubmitMapsInvalidOrderState(): void + { + $this->enqueueError(409, ['error' => 'wrong state', 'error_code' => 'INVALID_ORDER_STATE']); + $client = new Client(['transport' => $this->makeTransport()]); + $this->expectException(InvalidOrderStateException::class); + $client->transactionOrders->submit('acct-1', 'ord-1', 'OTP-12345'); + } + + public function testCancelMutates(): void + { + $this->enqueueOk(self::orderFixture('cancelled')); + $client = new Client(['transport' => $this->makeTransport()]); + $order = $client->transactionOrders->cancel('acct-1', 'ord-1'); + + self::assertSame('cancelled', $order->status); + self::assertSame('POST', $this->lastMethod()); + self::assertStringEndsWith('/cancel', $this->lastUrl()); + } +} diff --git a/packages/php/tests/V2/TransactionsTest.php b/packages/php/tests/V2/TransactionsTest.php new file mode 100644 index 0000000..565db26 --- /dev/null +++ b/packages/php/tests/V2/TransactionsTest.php @@ -0,0 +1,109 @@ +enqueueOk([ + 'id' => 'txn-1', + 'status' => 'posted', + 'data' => [ + 'amount_cents' => 100, + 'currency' => 'VES', + 'description' => 'x', + 'transaction_date' => '2026-04-20', + ], + 'tesote_imported_at' => 't', + 'tesote_updated_at' => 't', + 'transaction_categories' => [], + 'counterparty' => null, + ]); + $client = new Client(['transport' => $this->makeTransport()]); + $txn = $client->transactions->get('txn-1'); + self::assertInstanceOf(Transaction::class, $txn); + self::assertSame('txn-1', $txn->id); + self::assertStringEndsWith('/v2/transactions/txn-1', $this->lastUrl()); + } + + public function testGetMapsNotFound(): void + { + $this->enqueueError(404, ['error' => 'gone', 'error_code' => 'TRANSACTION_NOT_FOUND']); + $client = new Client(['transport' => $this->makeTransport()]); + $this->expectException(TransactionNotFoundException::class); + $client->transactions->get('missing'); + } + + public function testLegacySync(): void + { + $this->enqueueOk([ + 'added' => [], 'modified' => [], 'removed' => [], 'next_cursor' => null, 'has_more' => false, + ]); + $client = new Client(['transport' => $this->makeTransport()]); + $client->transactions->sync(['count' => 50, 'cursor' => null]); + self::assertSame('POST', $this->lastMethod()); + self::assertStringEndsWith('/v2/transactions/sync', $this->lastUrl()); + // why: SDK auto-generates an idempotency key for mutations even without an explicit caller value. + self::assertNotEmpty($this->lastHeaders()['Idempotency-Key']); + } + + public function testBulkRespectsBody(): void + { + $this->enqueueOk([ + 'bulk_results' => [ + [ + 'account_id' => 'a-1', + 'transactions' => [], + 'pagination' => ['has_more' => false, 'per_page' => 50, 'after_id' => null, 'before_id' => null], + ], + ], + ]); + $client = new Client(['transport' => $this->makeTransport()]); + $bulk = $client->transactions->bulk(['account_ids' => ['a-1', 'a-2'], 'limit' => 50]); + + self::assertInstanceOf(BulkResult::class, $bulk); + self::assertSame('a-1', $bulk->bulkResults[0]->accountId); + + $body = json_decode($this->lastBody() ?? '', true); + self::assertSame(['a-1', 'a-2'], $body['account_ids']); + } + + public function testBulkMapsUnprocessable(): void + { + $this->enqueueError(422, ['error' => 'too many', 'error_code' => 'UNPROCESSABLE_CONTENT']); + $client = new Client(['transport' => $this->makeTransport()]); + $this->expectException(UnprocessableContentException::class); + $client->transactions->bulk(['account_ids' => array_fill(0, 200, 'x')]); + } + + public function testSearchBuildsQuery(): void + { + $this->enqueueOk(['transactions' => [], 'total' => 0]); + $client = new Client(['transport' => $this->makeTransport()]); + $result = $client->transactions->search(['q' => 'coffee', 'limit' => 25]); + + self::assertInstanceOf(TransactionSearchResult::class, $result); + self::assertStringContainsString('q=coffee', $this->lastUrl()); + self::assertStringContainsString('limit=25', $this->lastUrl()); + } + + public function testSearchMapsInvalidQuery(): void + { + $this->enqueueError(422, ['error' => 'no q', 'error_code' => 'INVALID_QUERY']); + $client = new Client(['transport' => $this->makeTransport()]); + $this->expectException(InvalidQueryException::class); + $client->transactions->search(['q' => '']); + } +} From d9726f0029423471379fbbab3687d711f15adc45 Mon Sep 17 00:00:00 2001 From: sebi Date: Tue, 28 Apr 2026 19:44:03 -0500 Subject: [PATCH 05/10] python: implement full v1+v2 surface (0.2.0) 35 endpoints (6 v1 + 29 v2) wired against the Rails-controller-derived spec. Replaces v2/_stubs.py with real per-resource modules (transactions, sync_sessions, transaction_orders, batches, payment_methods, status). Adds 20+ typed exception classes per error_code, frozen @dataclass models for every response shape, cursor + offset iter_* generators, and request_raw transport path for CSV/JSON export. ruff/mypy clean, 103 pytest passes. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/python/pyproject.toml | 2 +- packages/python/src/tesote_sdk/__init__.py | 99 ++ packages/python/src/tesote_sdk/_version.py | 2 +- packages/python/src/tesote_sdk/errors.py | 156 +++ packages/python/src/tesote_sdk/models.py | 990 ++++++++++++++++++ packages/python/src/tesote_sdk/v1/accounts.py | 45 +- packages/python/src/tesote_sdk/v1/client.py | 9 +- packages/python/src/tesote_sdk/v1/status.py | 21 +- .../python/src/tesote_sdk/v1/transactions.py | 83 +- packages/python/src/tesote_sdk/v2/_stubs.py | 109 -- packages/python/src/tesote_sdk/v2/accounts.py | 62 +- packages/python/src/tesote_sdk/v2/batches.py | 109 ++ packages/python/src/tesote_sdk/v2/client.py | 21 +- .../src/tesote_sdk/v2/payment_methods.py | 166 +++ packages/python/src/tesote_sdk/v2/status.py | 28 + .../python/src/tesote_sdk/v2/sync_sessions.py | 76 ++ .../src/tesote_sdk/v2/transaction_orders.py | 169 +++ .../python/src/tesote_sdk/v2/transactions.py | 259 +++++ packages/python/tests/_helpers.py | 19 + packages/python/tests/test_v1_accounts.py | 82 ++ packages/python/tests/test_v1_status.py | 39 + packages/python/tests/test_v1_transactions.py | 125 +++ packages/python/tests/test_v2_accounts.py | 174 +++ packages/python/tests/test_v2_batches.py | 139 +++ .../python/tests/test_v2_payment_methods.py | 148 +++ packages/python/tests/test_v2_status.py | 25 + .../python/tests/test_v2_sync_sessions.py | 120 +++ .../tests/test_v2_transaction_orders.py | 182 ++++ packages/python/tests/test_v2_transactions.py | 256 +++++ 29 files changed, 3544 insertions(+), 171 deletions(-) create mode 100644 packages/python/src/tesote_sdk/models.py delete mode 100644 packages/python/src/tesote_sdk/v2/_stubs.py create mode 100644 packages/python/src/tesote_sdk/v2/batches.py create mode 100644 packages/python/src/tesote_sdk/v2/payment_methods.py create mode 100644 packages/python/src/tesote_sdk/v2/status.py create mode 100644 packages/python/src/tesote_sdk/v2/sync_sessions.py create mode 100644 packages/python/src/tesote_sdk/v2/transaction_orders.py create mode 100644 packages/python/src/tesote_sdk/v2/transactions.py create mode 100644 packages/python/tests/_helpers.py create mode 100644 packages/python/tests/test_v1_accounts.py create mode 100644 packages/python/tests/test_v1_status.py create mode 100644 packages/python/tests/test_v1_transactions.py create mode 100644 packages/python/tests/test_v2_accounts.py create mode 100644 packages/python/tests/test_v2_batches.py create mode 100644 packages/python/tests/test_v2_payment_methods.py create mode 100644 packages/python/tests/test_v2_status.py create mode 100644 packages/python/tests/test_v2_sync_sessions.py create mode 100644 packages/python/tests/test_v2_transaction_orders.py create mode 100644 packages/python/tests/test_v2_transactions.py diff --git a/packages/python/pyproject.toml b/packages/python/pyproject.toml index 5f471ac..532a7b9 100644 --- a/packages/python/pyproject.toml +++ b/packages/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "tesote-sdk" -version = "0.1.0" +version = "0.2.0" description = "Official Python SDK for the equipo.tesote.com API." readme = "README.md" requires-python = ">=3.9" diff --git a/packages/python/src/tesote_sdk/__init__.py b/packages/python/src/tesote_sdk/__init__.py index 01f8397..80ded2e 100644 --- a/packages/python/src/tesote_sdk/__init__.py +++ b/packages/python/src/tesote_sdk/__init__.py @@ -6,24 +6,74 @@ from ._version import __version__ from .errors import ( AccountDisabledError, + AccountNotFoundError, ApiError, ApiKeyRevokedError, + BankConnectionNotFoundError, + BankSubmissionError, + BankUnderMaintenanceError, + BatchNotFoundError, + BatchValidationError, ConfigError, EndpointRemovedError, HistorySyncForbiddenError, + InternalError, + InvalidCountError, + InvalidCursorError, InvalidDateRangeError, + InvalidLimitError, + InvalidOrderStateError, + InvalidQueryError, + MissingDateRangeError, MutationDuringPaginationError, NetworkError, + NotFoundError, + PaymentMethodNotFoundError, RateLimitExceededError, ServiceUnavailableError, + SyncInProgressError, + SyncRateLimitExceededError, + SyncSessionNotFoundError, TesoteError, TimeoutError, TlsError, + TransactionNotFoundError, + TransactionOrderNotFoundError, TransportError, UnauthorizedError, UnprocessableContentError, + ValidationError, WorkspaceSuspendedError, ) +from .models import ( + Account, + AccountList, + AccountSyncStarted, + BatchApproveResult, + BatchCancelResult, + BatchCreateResult, + BatchSubmitResult, + BatchSummary, + BulkAccountResult, + BulkResult, + Counterparty, + CursorInfo, + PageInfo, + PaymentMethod, + PaymentMethodList, + RemovedSyncTransaction, + SearchResult, + StatusResponse, + SyncDelta, + SyncSession, + SyncSessionList, + SyncTransaction, + Transaction, + TransactionList, + TransactionOrder, + TransactionOrderList, + WhoAmI, +) from .v1 import V1Client from .v2 import V2Client @@ -45,9 +95,58 @@ "WorkspaceSuspendedError", "AccountDisabledError", "HistorySyncForbiddenError", + "NotFoundError", + "AccountNotFoundError", + "TransactionNotFoundError", + "SyncSessionNotFoundError", + "PaymentMethodNotFoundError", + "TransactionOrderNotFoundError", + "BatchNotFoundError", + "BankConnectionNotFoundError", "MutationDuringPaginationError", + "SyncInProgressError", + "InvalidOrderStateError", + "ValidationError", + "BatchValidationError", "UnprocessableContentError", "InvalidDateRangeError", + "MissingDateRangeError", + "InvalidCursorError", + "InvalidCountError", + "InvalidLimitError", + "InvalidQueryError", + "BankSubmissionError", "RateLimitExceededError", + "SyncRateLimitExceededError", "ServiceUnavailableError", + "BankUnderMaintenanceError", + "InternalError", + # models + "Account", + "AccountList", + "AccountSyncStarted", + "BatchApproveResult", + "BatchCancelResult", + "BatchCreateResult", + "BatchSubmitResult", + "BatchSummary", + "BulkAccountResult", + "BulkResult", + "Counterparty", + "CursorInfo", + "PageInfo", + "PaymentMethod", + "PaymentMethodList", + "RemovedSyncTransaction", + "SearchResult", + "StatusResponse", + "SyncDelta", + "SyncSession", + "SyncSessionList", + "SyncTransaction", + "Transaction", + "TransactionList", + "TransactionOrder", + "TransactionOrderList", + "WhoAmI", ] diff --git a/packages/python/src/tesote_sdk/_version.py b/packages/python/src/tesote_sdk/_version.py index ead3de8..17d6027 100644 --- a/packages/python/src/tesote_sdk/_version.py +++ b/packages/python/src/tesote_sdk/_version.py @@ -3,4 +3,4 @@ Kept in sync manually with ``pyproject.toml``. CI verifies on release. """ -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/packages/python/src/tesote_sdk/errors.py b/packages/python/src/tesote_sdk/errors.py index 38d35f0..c5f9190 100644 --- a/packages/python/src/tesote_sdk/errors.py +++ b/packages/python/src/tesote_sdk/errors.py @@ -81,6 +81,9 @@ class ApiError(TesoteError): """Server returned a structured error envelope.""" +# 401 family ----------------------------------------------------------------- + + class UnauthorizedError(ApiError): """401 ``UNAUTHORIZED``.""" @@ -89,6 +92,9 @@ class ApiKeyRevokedError(ApiError): """401 ``API_KEY_REVOKED``.""" +# 403 family ----------------------------------------------------------------- + + class WorkspaceSuspendedError(ApiError): """403 ``WORKSPACE_SUSPENDED``.""" @@ -101,10 +107,67 @@ class HistorySyncForbiddenError(ApiError): """403 ``HISTORY_SYNC_FORBIDDEN``.""" +# 404 family ----------------------------------------------------------------- + + +class NotFoundError(ApiError): + """Generic 404 fallback when a more specific subclass does not match.""" + + +class AccountNotFoundError(NotFoundError): + """404 ``ACCOUNT_NOT_FOUND``.""" + + +class TransactionNotFoundError(NotFoundError): + """404 ``TRANSACTION_NOT_FOUND``.""" + + +class SyncSessionNotFoundError(NotFoundError): + """404 ``SYNC_SESSION_NOT_FOUND``.""" + + +class PaymentMethodNotFoundError(NotFoundError): + """404 ``PAYMENT_METHOD_NOT_FOUND``.""" + + +class TransactionOrderNotFoundError(NotFoundError): + """404 ``TRANSACTION_ORDER_NOT_FOUND``.""" + + +class BatchNotFoundError(NotFoundError): + """404 ``BATCH_NOT_FOUND``.""" + + +class BankConnectionNotFoundError(NotFoundError): + """404 ``BANK_CONNECTION_NOT_FOUND``.""" + + +# 409 family ----------------------------------------------------------------- + + class MutationDuringPaginationError(ApiError): """409 ``MUTATION_CONFLICT`` while iterating a cursor.""" +class SyncInProgressError(ApiError): + """409 ``SYNC_IN_PROGRESS``.""" + + +class InvalidOrderStateError(ApiError): + """409 ``INVALID_ORDER_STATE``.""" + + +# 400/422 validation family -------------------------------------------------- + + +class ValidationError(ApiError): + """400 ``VALIDATION_ERROR``.""" + + +class BatchValidationError(ApiError): + """400 ``BATCH_VALIDATION_ERROR``.""" + + class UnprocessableContentError(ApiError): """422 ``UNPROCESSABLE_CONTENT`` -- generic validation failure.""" @@ -113,14 +176,56 @@ class InvalidDateRangeError(ApiError): """422 ``INVALID_DATE_RANGE``.""" +class MissingDateRangeError(ApiError): + """422 ``MISSING_DATE_RANGE``.""" + + +class InvalidCursorError(ApiError): + """422 ``INVALID_CURSOR``.""" + + +class InvalidCountError(ApiError): + """422 ``INVALID_COUNT``.""" + + +class InvalidLimitError(ApiError): + """422 ``INVALID_LIMIT``.""" + + +class InvalidQueryError(ApiError): + """422 ``INVALID_QUERY``.""" + + +class BankSubmissionError(ApiError): + """422 ``BANK_SUBMISSION_ERROR``.""" + + +# 429 family ----------------------------------------------------------------- + + class RateLimitExceededError(ApiError): """429 ``RATE_LIMIT_EXCEEDED``.""" +class SyncRateLimitExceededError(ApiError): + """429 ``SYNC_RATE_LIMIT_EXCEEDED``.""" + + +# 5xx family ----------------------------------------------------------------- + + class ServiceUnavailableError(ApiError): """503 -- platform pause mode.""" +class BankUnderMaintenanceError(ApiError): + """503 ``BANK_UNDER_MAINTENANCE``.""" + + +class InternalError(ApiError): + """500 ``INTERNAL_ERROR``.""" + + # why: error_code dispatch table; keep in sync with docs/architecture/errors.md ERROR_CODE_TO_CLASS: Dict[str, Type[ApiError]] = { "UNAUTHORIZED": UnauthorizedError, @@ -128,10 +233,30 @@ class ServiceUnavailableError(ApiError): "WORKSPACE_SUSPENDED": WorkspaceSuspendedError, "ACCOUNT_DISABLED": AccountDisabledError, "HISTORY_SYNC_FORBIDDEN": HistorySyncForbiddenError, + "ACCOUNT_NOT_FOUND": AccountNotFoundError, + "TRANSACTION_NOT_FOUND": TransactionNotFoundError, + "SYNC_SESSION_NOT_FOUND": SyncSessionNotFoundError, + "PAYMENT_METHOD_NOT_FOUND": PaymentMethodNotFoundError, + "TRANSACTION_ORDER_NOT_FOUND": TransactionOrderNotFoundError, + "BATCH_NOT_FOUND": BatchNotFoundError, + "BANK_CONNECTION_NOT_FOUND": BankConnectionNotFoundError, "MUTATION_CONFLICT": MutationDuringPaginationError, + "SYNC_IN_PROGRESS": SyncInProgressError, + "INVALID_ORDER_STATE": InvalidOrderStateError, + "VALIDATION_ERROR": ValidationError, + "BATCH_VALIDATION_ERROR": BatchValidationError, "UNPROCESSABLE_CONTENT": UnprocessableContentError, "INVALID_DATE_RANGE": InvalidDateRangeError, + "MISSING_DATE_RANGE": MissingDateRangeError, + "INVALID_CURSOR": InvalidCursorError, + "INVALID_COUNT": InvalidCountError, + "INVALID_LIMIT": InvalidLimitError, + "INVALID_QUERY": InvalidQueryError, + "BANK_SUBMISSION_ERROR": BankSubmissionError, "RATE_LIMIT_EXCEEDED": RateLimitExceededError, + "SYNC_RATE_LIMIT_EXCEEDED": SyncRateLimitExceededError, + "BANK_UNDER_MAINTENANCE": BankUnderMaintenanceError, + "INTERNAL_ERROR": InternalError, } @@ -139,9 +264,11 @@ class ServiceUnavailableError(ApiError): HTTP_STATUS_TO_CLASS: Dict[int, Type[ApiError]] = { 401: UnauthorizedError, 403: WorkspaceSuspendedError, + 404: NotFoundError, 409: MutationDuringPaginationError, 422: UnprocessableContentError, 429: RateLimitExceededError, + 500: InternalError, 503: ServiceUnavailableError, } @@ -167,16 +294,45 @@ def classify_api_error( "TimeoutError", "TlsError", "ApiError", + # 401 "UnauthorizedError", "ApiKeyRevokedError", + # 403 "WorkspaceSuspendedError", "AccountDisabledError", "HistorySyncForbiddenError", + # 404 + "NotFoundError", + "AccountNotFoundError", + "TransactionNotFoundError", + "SyncSessionNotFoundError", + "PaymentMethodNotFoundError", + "TransactionOrderNotFoundError", + "BatchNotFoundError", + "BankConnectionNotFoundError", + # 409 "MutationDuringPaginationError", + "SyncInProgressError", + "InvalidOrderStateError", + # 400/422 validation + "ValidationError", + "BatchValidationError", "UnprocessableContentError", "InvalidDateRangeError", + "MissingDateRangeError", + "InvalidCursorError", + "InvalidCountError", + "InvalidLimitError", + "InvalidQueryError", + "BankSubmissionError", + # 429 "RateLimitExceededError", + "SyncRateLimitExceededError", + # 5xx "ServiceUnavailableError", + "BankUnderMaintenanceError", + "InternalError", + # tables "ERROR_CODE_TO_CLASS", "HTTP_STATUS_TO_CLASS", "classify_api_error", diff --git a/packages/python/src/tesote_sdk/models.py b/packages/python/src/tesote_sdk/models.py new file mode 100644 index 0000000..6a8ca92 --- /dev/null +++ b/packages/python/src/tesote_sdk/models.py @@ -0,0 +1,990 @@ +"""Typed dataclass models for v1+v2 response bodies. + +All models are immutable (`@dataclass(frozen=True)`). Field names mirror the +wire snake_case exactly. ``from_dict`` factories accept partial server payloads: +unknown keys are dropped, missing keys default to ``None`` / empty containers. + +Stdlib only -- no runtime deps. Built for Python 3.9 (no PEP 604 unions, no +PEP 585 builtin generics in annotations). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Mapping, Optional + + +def _as_str(value: Any) -> Optional[str]: + if value is None: + return None + if isinstance(value, str): + return value + return str(value) + + +def _as_int(value: Any) -> Optional[int]: + if value is None: + return None + if isinstance(value, bool): + # why: bool is an int in Python; we don't want True -> 1 silently + return None + if isinstance(value, int): + return value + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _as_float(value: Any) -> Optional[float]: + if value is None: + return None + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + return float(value) + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _as_bool(value: Any) -> Optional[bool]: + if value is None: + return None + if isinstance(value, bool): + return value + if isinstance(value, str): + if value.lower() in {"true", "1", "yes"}: + return True + if value.lower() in {"false", "0", "no"}: + return False + return None + + +def _as_dict(value: Any) -> Dict[str, Any]: + if isinstance(value, dict): + return dict(value) + return {} + + +def _as_list(value: Any) -> List[Any]: + if isinstance(value, list): + return list(value) + return [] + + +# Account --------------------------------------------------------------------- + + +@dataclass(frozen=True) +class AccountBank: + name: Optional[str] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> AccountBank: + return cls(name=_as_str(data.get("name"))) + + +@dataclass(frozen=True) +class AccountLegalEntity: + id: Optional[str] + legal_name: Optional[str] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> AccountLegalEntity: + return cls( + id=_as_str(data.get("id")), + legal_name=_as_str(data.get("legal_name")), + ) + + +@dataclass(frozen=True) +class AccountData: + masked_account_number: Optional[str] + currency: Optional[str] + transactions_data_current_as_of: Optional[str] + balance_data_current_as_of: Optional[str] + custom_user_provided_identifier: Optional[str] + balance_cents: Optional[str] + available_balance_cents: Optional[str] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> AccountData: + return cls( + masked_account_number=_as_str(data.get("masked_account_number")), + currency=_as_str(data.get("currency")), + transactions_data_current_as_of=_as_str(data.get("transactions_data_current_as_of")), + balance_data_current_as_of=_as_str(data.get("balance_data_current_as_of")), + custom_user_provided_identifier=_as_str(data.get("custom_user_provided_identifier")), + balance_cents=_as_str(data.get("balance_cents")), + available_balance_cents=_as_str(data.get("available_balance_cents")), + ) + + +@dataclass(frozen=True) +class Account: + id: Optional[str] + name: Optional[str] + data: AccountData + bank: AccountBank + legal_entity: AccountLegalEntity + tesote_created_at: Optional[str] + tesote_updated_at: Optional[str] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> Account: + return cls( + id=_as_str(data.get("id")), + name=_as_str(data.get("name")), + data=AccountData.from_dict(_as_dict(data.get("data"))), + bank=AccountBank.from_dict(_as_dict(data.get("bank"))), + legal_entity=AccountLegalEntity.from_dict(_as_dict(data.get("legal_entity"))), + tesote_created_at=_as_str(data.get("tesote_created_at")), + tesote_updated_at=_as_str(data.get("tesote_updated_at")), + ) + + +# Transaction (v1 schema) ----------------------------------------------------- + + +@dataclass(frozen=True) +class TransactionCategory: + name: Optional[str] + external_category_code: Optional[str] + created_at: Optional[str] + updated_at: Optional[str] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> TransactionCategory: + return cls( + name=_as_str(data.get("name")), + external_category_code=_as_str(data.get("external_category_code")), + created_at=_as_str(data.get("created_at")), + updated_at=_as_str(data.get("updated_at")), + ) + + +@dataclass(frozen=True) +class Counterparty: + name: Optional[str] + id: Optional[str] = None + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> Counterparty: + return cls( + name=_as_str(data.get("name")), + id=_as_str(data.get("id")), + ) + + +@dataclass(frozen=True) +class TransactionData: + amount_cents: Optional[int] + currency: Optional[str] + description: Optional[str] + transaction_date: Optional[str] + created_at: Optional[str] + created_at_date: Optional[str] + note: Optional[str] + external_service_id: Optional[str] + running_balance_cents: Optional[int] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> TransactionData: + return cls( + amount_cents=_as_int(data.get("amount_cents")), + currency=_as_str(data.get("currency")), + description=_as_str(data.get("description")), + transaction_date=_as_str(data.get("transaction_date")), + created_at=_as_str(data.get("created_at")), + created_at_date=_as_str(data.get("created_at_date")), + note=_as_str(data.get("note")), + external_service_id=_as_str(data.get("external_service_id")), + running_balance_cents=_as_int(data.get("running_balance_cents")), + ) + + +@dataclass(frozen=True) +class Transaction: + id: Optional[str] + status: Optional[str] + data: TransactionData + tesote_imported_at: Optional[str] + tesote_updated_at: Optional[str] + transaction_categories: List[TransactionCategory] = field(default_factory=list) + counterparty: Optional[Counterparty] = None + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> Transaction: + cats_raw = _as_list(data.get("transaction_categories")) + categories = [TransactionCategory.from_dict(c) for c in cats_raw if isinstance(c, dict)] + cp_raw = data.get("counterparty") + counterparty = ( + Counterparty.from_dict(cp_raw) if isinstance(cp_raw, dict) else None + ) + return cls( + id=_as_str(data.get("id")), + status=_as_str(data.get("status")), + data=TransactionData.from_dict(_as_dict(data.get("data"))), + tesote_imported_at=_as_str(data.get("tesote_imported_at")), + tesote_updated_at=_as_str(data.get("tesote_updated_at")), + transaction_categories=categories, + counterparty=counterparty, + ) + + +# SyncTransaction (v2 sync) --------------------------------------------------- + + +@dataclass(frozen=True) +class SyncTransaction: + transaction_id: Optional[str] + account_id: Optional[str] + amount: Optional[float] + iso_currency_code: Optional[str] + unofficial_currency_code: Optional[str] + date: Optional[str] + datetime: Optional[str] + name: Optional[str] + merchant_name: Optional[str] + pending: Optional[bool] + category: List[str] = field(default_factory=list) + running_balance_cents: Optional[int] = None + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> SyncTransaction: + cats = [str(c) for c in _as_list(data.get("category")) if isinstance(c, str)] + return cls( + transaction_id=_as_str(data.get("transaction_id")), + account_id=_as_str(data.get("account_id")), + amount=_as_float(data.get("amount")), + iso_currency_code=_as_str(data.get("iso_currency_code")), + unofficial_currency_code=_as_str(data.get("unofficial_currency_code")), + date=_as_str(data.get("date")), + datetime=_as_str(data.get("datetime")), + name=_as_str(data.get("name")), + merchant_name=_as_str(data.get("merchant_name")), + pending=_as_bool(data.get("pending")), + category=cats, + running_balance_cents=_as_int(data.get("running_balance_cents")), + ) + + +@dataclass(frozen=True) +class RemovedSyncTransaction: + transaction_id: Optional[str] + account_id: Optional[str] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> RemovedSyncTransaction: + return cls( + transaction_id=_as_str(data.get("transaction_id")), + account_id=_as_str(data.get("account_id")), + ) + + +@dataclass(frozen=True) +class SyncDelta: + added: List[SyncTransaction] + modified: List[SyncTransaction] + removed: List[RemovedSyncTransaction] + next_cursor: Optional[str] + has_more: bool + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> SyncDelta: + added = [ + SyncTransaction.from_dict(item) + for item in _as_list(data.get("added")) + if isinstance(item, dict) + ] + modified = [ + SyncTransaction.from_dict(item) + for item in _as_list(data.get("modified")) + if isinstance(item, dict) + ] + removed = [ + RemovedSyncTransaction.from_dict(item) + for item in _as_list(data.get("removed")) + if isinstance(item, dict) + ] + has_more_value = _as_bool(data.get("has_more")) + return cls( + added=added, + modified=modified, + removed=removed, + next_cursor=_as_str(data.get("next_cursor")), + has_more=bool(has_more_value) if has_more_value is not None else False, + ) + + +# TransactionOrder ------------------------------------------------------------ + + +@dataclass(frozen=True) +class OrderSourceAccount: + id: Optional[str] + name: Optional[str] + payment_method_id: Optional[str] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> OrderSourceAccount: + return cls( + id=_as_str(data.get("id")), + name=_as_str(data.get("name")), + payment_method_id=_as_str(data.get("payment_method_id")), + ) + + +@dataclass(frozen=True) +class OrderDestination: + payment_method_id: Optional[str] + counterparty_id: Optional[str] + counterparty_name: Optional[str] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> OrderDestination: + return cls( + payment_method_id=_as_str(data.get("payment_method_id")), + counterparty_id=_as_str(data.get("counterparty_id")), + counterparty_name=_as_str(data.get("counterparty_name")), + ) + + +@dataclass(frozen=True) +class Fee: + amount: Optional[float] + currency: Optional[str] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> Fee: + return cls( + amount=_as_float(data.get("amount")), + currency=_as_str(data.get("currency")), + ) + + +@dataclass(frozen=True) +class TesoteTransactionRef: + id: Optional[str] + status: Optional[str] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> TesoteTransactionRef: + return cls( + id=_as_str(data.get("id")), + status=_as_str(data.get("status")), + ) + + +@dataclass(frozen=True) +class TransactionOrderAttempt: + id: Optional[str] + status: Optional[str] + attempt_number: Optional[int] + external_reference: Optional[str] + submitted_at: Optional[str] + completed_at: Optional[str] + error_code: Optional[str] + error_message: Optional[str] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> TransactionOrderAttempt: + return cls( + id=_as_str(data.get("id")), + status=_as_str(data.get("status")), + attempt_number=_as_int(data.get("attempt_number")), + external_reference=_as_str(data.get("external_reference")), + submitted_at=_as_str(data.get("submitted_at")), + completed_at=_as_str(data.get("completed_at")), + error_code=_as_str(data.get("error_code")), + error_message=_as_str(data.get("error_message")), + ) + + +@dataclass(frozen=True) +class TransactionOrder: + id: Optional[str] + status: Optional[str] + amount: Optional[float] + currency: Optional[str] + description: Optional[str] + reference: Optional[str] + external_reference: Optional[str] + idempotency_key: Optional[str] + batch_id: Optional[str] + scheduled_for: Optional[str] + approved_at: Optional[str] + submitted_at: Optional[str] + completed_at: Optional[str] + failed_at: Optional[str] + cancelled_at: Optional[str] + source_account: Optional[OrderSourceAccount] + destination: Optional[OrderDestination] + fee: Optional[Fee] + execution_strategy: Optional[str] + tesote_transaction: Optional[TesoteTransactionRef] + latest_attempt: Optional[TransactionOrderAttempt] + created_at: Optional[str] + updated_at: Optional[str] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> TransactionOrder: + src = data.get("source_account") + dst = data.get("destination") + fee = data.get("fee") + ttx = data.get("tesote_transaction") + latest = data.get("latest_attempt") + return cls( + id=_as_str(data.get("id")), + status=_as_str(data.get("status")), + amount=_as_float(data.get("amount")), + currency=_as_str(data.get("currency")), + description=_as_str(data.get("description")), + reference=_as_str(data.get("reference")), + external_reference=_as_str(data.get("external_reference")), + idempotency_key=_as_str(data.get("idempotency_key")), + batch_id=_as_str(data.get("batch_id")), + scheduled_for=_as_str(data.get("scheduled_for")), + approved_at=_as_str(data.get("approved_at")), + submitted_at=_as_str(data.get("submitted_at")), + completed_at=_as_str(data.get("completed_at")), + failed_at=_as_str(data.get("failed_at")), + cancelled_at=_as_str(data.get("cancelled_at")), + source_account=OrderSourceAccount.from_dict(src) if isinstance(src, dict) else None, + destination=OrderDestination.from_dict(dst) if isinstance(dst, dict) else None, + fee=Fee.from_dict(fee) if isinstance(fee, dict) else None, + execution_strategy=_as_str(data.get("execution_strategy")), + tesote_transaction=( + TesoteTransactionRef.from_dict(ttx) if isinstance(ttx, dict) else None + ), + latest_attempt=( + TransactionOrderAttempt.from_dict(latest) if isinstance(latest, dict) else None + ), + created_at=_as_str(data.get("created_at")), + updated_at=_as_str(data.get("updated_at")), + ) + + +# PaymentMethod --------------------------------------------------------------- + + +@dataclass(frozen=True) +class PaymentMethodAccountRef: + id: Optional[str] + name: Optional[str] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> PaymentMethodAccountRef: + return cls( + id=_as_str(data.get("id")), + name=_as_str(data.get("name")), + ) + + +@dataclass(frozen=True) +class PaymentMethod: + id: Optional[str] + method_type: Optional[str] + currency: Optional[str] + label: Optional[str] + details: Dict[str, Any] + verified: Optional[bool] + verified_at: Optional[str] + last_used_at: Optional[str] + counterparty: Optional[Counterparty] + tesote_account: Optional[PaymentMethodAccountRef] + created_at: Optional[str] + updated_at: Optional[str] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> PaymentMethod: + cp = data.get("counterparty") + acc = data.get("tesote_account") + return cls( + id=_as_str(data.get("id")), + method_type=_as_str(data.get("method_type")), + currency=_as_str(data.get("currency")), + label=_as_str(data.get("label")), + details=_as_dict(data.get("details")), + verified=_as_bool(data.get("verified")), + verified_at=_as_str(data.get("verified_at")), + last_used_at=_as_str(data.get("last_used_at")), + counterparty=Counterparty.from_dict(cp) if isinstance(cp, dict) else None, + tesote_account=( + PaymentMethodAccountRef.from_dict(acc) if isinstance(acc, dict) else None + ), + created_at=_as_str(data.get("created_at")), + updated_at=_as_str(data.get("updated_at")), + ) + + +# SyncSession ----------------------------------------------------------------- + + +@dataclass(frozen=True) +class SyncSessionError: + type: Optional[str] + message: Optional[str] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> SyncSessionError: + return cls( + type=_as_str(data.get("type")), + message=_as_str(data.get("message")), + ) + + +@dataclass(frozen=True) +class SyncSessionPerformance: + total_duration: Optional[float] + complexity_score: Optional[float] + sync_speed_score: Optional[float] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> SyncSessionPerformance: + return cls( + total_duration=_as_float(data.get("total_duration")), + complexity_score=_as_float(data.get("complexity_score")), + sync_speed_score=_as_float(data.get("sync_speed_score")), + ) + + +@dataclass(frozen=True) +class SyncSession: + id: Optional[str] + status: Optional[str] + started_at: Optional[str] + completed_at: Optional[str] + transactions_synced: Optional[int] + accounts_count: Optional[int] + error: Optional[SyncSessionError] + performance: Optional[SyncSessionPerformance] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> SyncSession: + err = data.get("error") + perf = data.get("performance") + return cls( + id=_as_str(data.get("id")), + status=_as_str(data.get("status")), + started_at=_as_str(data.get("started_at")), + completed_at=_as_str(data.get("completed_at")), + transactions_synced=_as_int(data.get("transactions_synced")), + accounts_count=_as_int(data.get("accounts_count")), + error=SyncSessionError.from_dict(err) if isinstance(err, dict) else None, + performance=( + SyncSessionPerformance.from_dict(perf) if isinstance(perf, dict) else None + ), + ) + + +# Sync trigger response (POST /v2/accounts/{id}/sync) ------------------------ + + +@dataclass(frozen=True) +class AccountSyncStarted: + message: Optional[str] + sync_session_id: Optional[str] + status: Optional[str] + started_at: Optional[str] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> AccountSyncStarted: + return cls( + message=_as_str(data.get("message")), + sync_session_id=_as_str(data.get("sync_session_id")), + status=_as_str(data.get("status")), + started_at=_as_str(data.get("started_at")), + ) + + +# Batch summary --------------------------------------------------------------- + + +@dataclass(frozen=True) +class BatchSummary: + batch_id: Optional[str] + total_orders: Optional[int] + total_amount_cents: Optional[int] + amount_currency: Optional[str] + statuses: Dict[str, int] + batch_status: Optional[str] + created_at: Optional[str] + orders: List[TransactionOrder] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> BatchSummary: + statuses_raw = _as_dict(data.get("statuses")) + statuses: Dict[str, int] = {} + for k, v in statuses_raw.items(): + iv = _as_int(v) + if iv is not None: + statuses[str(k)] = iv + orders = [ + TransactionOrder.from_dict(o) + for o in _as_list(data.get("orders")) + if isinstance(o, dict) + ] + return cls( + batch_id=_as_str(data.get("batch_id")), + total_orders=_as_int(data.get("total_orders")), + total_amount_cents=_as_int(data.get("total_amount_cents")), + amount_currency=_as_str(data.get("amount_currency")), + statuses=statuses, + batch_status=_as_str(data.get("batch_status")), + created_at=_as_str(data.get("created_at")), + orders=orders, + ) + + +@dataclass(frozen=True) +class BatchCreateResult: + batch_id: Optional[str] + orders: List[TransactionOrder] + errors: List[Dict[str, Any]] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> BatchCreateResult: + orders = [ + TransactionOrder.from_dict(o) + for o in _as_list(data.get("orders")) + if isinstance(o, dict) + ] + errs = [e for e in _as_list(data.get("errors")) if isinstance(e, dict)] + return cls( + batch_id=_as_str(data.get("batch_id")), + orders=orders, + errors=errs, + ) + + +@dataclass(frozen=True) +class BatchApproveResult: + approved: int + failed: int + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> BatchApproveResult: + return cls( + approved=_as_int(data.get("approved")) or 0, + failed=_as_int(data.get("failed")) or 0, + ) + + +@dataclass(frozen=True) +class BatchSubmitResult: + enqueued: int + failed: int + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> BatchSubmitResult: + return cls( + enqueued=_as_int(data.get("enqueued")) or 0, + failed=_as_int(data.get("failed")) or 0, + ) + + +@dataclass(frozen=True) +class BatchCancelResult: + cancelled: int + skipped: int + errors: List[Dict[str, Any]] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> BatchCancelResult: + errs = [e for e in _as_list(data.get("errors")) if isinstance(e, dict)] + return cls( + cancelled=_as_int(data.get("cancelled")) or 0, + skipped=_as_int(data.get("skipped")) or 0, + errors=errs, + ) + + +# Status / WhoAmI ------------------------------------------------------------- + + +@dataclass(frozen=True) +class StatusResponse: + status: Optional[str] + authenticated: Optional[bool] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> StatusResponse: + return cls( + status=_as_str(data.get("status")), + authenticated=_as_bool(data.get("authenticated")), + ) + + +@dataclass(frozen=True) +class WhoAmIClient: + id: Optional[str] + name: Optional[str] + type: Optional[str] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> WhoAmIClient: + return cls( + id=_as_str(data.get("id")), + name=_as_str(data.get("name")), + type=_as_str(data.get("type")), + ) + + +@dataclass(frozen=True) +class WhoAmI: + client: Optional[WhoAmIClient] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> WhoAmI: + cl = data.get("client") + return cls(client=WhoAmIClient.from_dict(cl) if isinstance(cl, dict) else None) + + +# Pagination shapes ----------------------------------------------------------- + + +@dataclass(frozen=True) +class PageInfo: + current_page: Optional[int] + per_page: Optional[int] + total_pages: Optional[int] + total_count: Optional[int] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> PageInfo: + return cls( + current_page=_as_int(data.get("current_page")), + per_page=_as_int(data.get("per_page")), + total_pages=_as_int(data.get("total_pages")), + total_count=_as_int(data.get("total_count")), + ) + + +@dataclass(frozen=True) +class CursorInfo: + has_more: bool + per_page: Optional[int] + after_id: Optional[str] + before_id: Optional[str] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> CursorInfo: + has_more_value = _as_bool(data.get("has_more")) + return cls( + has_more=bool(has_more_value) if has_more_value is not None else False, + per_page=_as_int(data.get("per_page")), + after_id=_as_str(data.get("after_id")), + before_id=_as_str(data.get("before_id")), + ) + + +@dataclass(frozen=True) +class AccountList: + total: Optional[int] + accounts: List[Account] + pagination: Optional[PageInfo] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> AccountList: + accounts = [ + Account.from_dict(a) + for a in _as_list(data.get("accounts")) + if isinstance(a, dict) + ] + page = data.get("pagination") + return cls( + total=_as_int(data.get("total")), + accounts=accounts, + pagination=PageInfo.from_dict(page) if isinstance(page, dict) else None, + ) + + +@dataclass(frozen=True) +class TransactionList: + total: Optional[int] + transactions: List[Transaction] + pagination: Optional[CursorInfo] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> TransactionList: + txns = [ + Transaction.from_dict(t) + for t in _as_list(data.get("transactions")) + if isinstance(t, dict) + ] + page = data.get("pagination") + return cls( + total=_as_int(data.get("total")), + transactions=txns, + pagination=CursorInfo.from_dict(page) if isinstance(page, dict) else None, + ) + + +@dataclass(frozen=True) +class BulkAccountResult: + account_id: Optional[str] + transactions: List[Transaction] + pagination: Optional[CursorInfo] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> BulkAccountResult: + txns = [ + Transaction.from_dict(t) + for t in _as_list(data.get("transactions")) + if isinstance(t, dict) + ] + page = data.get("pagination") + return cls( + account_id=_as_str(data.get("account_id")), + transactions=txns, + pagination=CursorInfo.from_dict(page) if isinstance(page, dict) else None, + ) + + +@dataclass(frozen=True) +class BulkResult: + bulk_results: List[BulkAccountResult] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> BulkResult: + items = [ + BulkAccountResult.from_dict(b) + for b in _as_list(data.get("bulk_results")) + if isinstance(b, dict) + ] + return cls(bulk_results=items) + + +@dataclass(frozen=True) +class SearchResult: + transactions: List[Transaction] + total: Optional[int] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> SearchResult: + txns = [ + Transaction.from_dict(t) + for t in _as_list(data.get("transactions")) + if isinstance(t, dict) + ] + return cls( + transactions=txns, + total=_as_int(data.get("total")), + ) + + +@dataclass(frozen=True) +class SyncSessionList: + sync_sessions: List[SyncSession] + limit: Optional[int] + offset: Optional[int] + has_more: bool + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> SyncSessionList: + sessions = [ + SyncSession.from_dict(s) + for s in _as_list(data.get("sync_sessions")) + if isinstance(s, dict) + ] + has_more_value = _as_bool(data.get("has_more")) + return cls( + sync_sessions=sessions, + limit=_as_int(data.get("limit")), + offset=_as_int(data.get("offset")), + has_more=bool(has_more_value) if has_more_value is not None else False, + ) + + +@dataclass(frozen=True) +class TransactionOrderList: + items: List[TransactionOrder] + has_more: bool + limit: Optional[int] + offset: Optional[int] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> TransactionOrderList: + items = [ + TransactionOrder.from_dict(o) + for o in _as_list(data.get("items")) + if isinstance(o, dict) + ] + has_more_value = _as_bool(data.get("has_more")) + return cls( + items=items, + has_more=bool(has_more_value) if has_more_value is not None else False, + limit=_as_int(data.get("limit")), + offset=_as_int(data.get("offset")), + ) + + +@dataclass(frozen=True) +class PaymentMethodList: + items: List[PaymentMethod] + has_more: bool + limit: Optional[int] + offset: Optional[int] + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> PaymentMethodList: + items = [ + PaymentMethod.from_dict(p) + for p in _as_list(data.get("items")) + if isinstance(p, dict) + ] + has_more_value = _as_bool(data.get("has_more")) + return cls( + items=items, + has_more=bool(has_more_value) if has_more_value is not None else False, + limit=_as_int(data.get("limit")), + offset=_as_int(data.get("offset")), + ) + + +__all__ = [ + "Account", + "AccountBank", + "AccountData", + "AccountLegalEntity", + "AccountList", + "AccountSyncStarted", + "BatchApproveResult", + "BatchCancelResult", + "BatchCreateResult", + "BatchSubmitResult", + "BatchSummary", + "BulkAccountResult", + "BulkResult", + "Counterparty", + "CursorInfo", + "Fee", + "OrderDestination", + "OrderSourceAccount", + "PageInfo", + "PaymentMethod", + "PaymentMethodAccountRef", + "PaymentMethodList", + "RemovedSyncTransaction", + "SearchResult", + "StatusResponse", + "SyncDelta", + "SyncSession", + "SyncSessionError", + "SyncSessionList", + "SyncSessionPerformance", + "SyncTransaction", + "TesoteTransactionRef", + "Transaction", + "TransactionCategory", + "TransactionData", + "TransactionList", + "TransactionOrder", + "TransactionOrderAttempt", + "TransactionOrderList", + "WhoAmI", + "WhoAmIClient", +] diff --git a/packages/python/src/tesote_sdk/v1/accounts.py b/packages/python/src/tesote_sdk/v1/accounts.py index 467ed48..14029b1 100644 --- a/packages/python/src/tesote_sdk/v1/accounts.py +++ b/packages/python/src/tesote_sdk/v1/accounts.py @@ -1,9 +1,10 @@ -"""v1 accounts resource. Read-only.""" +"""v1 accounts resource. Read-only on `/v1/accounts`.""" from __future__ import annotations -from typing import Any, Dict, List, Optional +from typing import Any, Dict, Optional +from ..models import Account, AccountList from ..transport import Transport @@ -15,25 +16,33 @@ class AccountsResource: def __init__(self, transport: Transport) -> None: self._transport = transport - def list(self, *, cache_ttl: Optional[float] = None) -> List[Dict[str, Any]]: - response = self._transport.request( - "GET", self._PREFIX, cache_ttl=cache_ttl - ) - body = response.json - if isinstance(body, list): - return [item for item in body if isinstance(item, dict)] - if isinstance(body, dict) and isinstance(body.get("data"), list): - return [item for item in body["data"] if isinstance(item, dict)] - return [] - - def get(self, account_id: str, *, cache_ttl: Optional[float] = None) -> Dict[str, Any]: + def list( + self, + *, + page: Optional[int] = None, + per_page: Optional[int] = None, + include: Optional[str] = None, + sort: Optional[str] = None, + cache_ttl: Optional[float] = None, + ) -> AccountList: + """GET /v1/accounts -- page-based pagination, ETag-cached for 60s upstream.""" + query: Dict[str, Any] = { + "page": page, + "per_page": per_page, + "include": include, + "sort": sort, + } + response = self._transport.request("GET", self._PREFIX, query=query, cache_ttl=cache_ttl) + body: Dict[str, Any] = response.json if isinstance(response.json, dict) else {} + return AccountList.from_dict(body) + + def get(self, account_id: str, *, cache_ttl: Optional[float] = None) -> Account: + """GET /v1/accounts/{id}.""" response = self._transport.request( "GET", f"{self._PREFIX}/{account_id}", cache_ttl=cache_ttl ) - body = response.json - if isinstance(body, dict): - return body - return {} + body: Dict[str, Any] = response.json if isinstance(response.json, dict) else {} + return Account.from_dict(body) __all__ = ["AccountsResource"] diff --git a/packages/python/src/tesote_sdk/v1/client.py b/packages/python/src/tesote_sdk/v1/client.py index c0cd27a..4016b7c 100644 --- a/packages/python/src/tesote_sdk/v1/client.py +++ b/packages/python/src/tesote_sdk/v1/client.py @@ -1,10 +1,11 @@ -"""v1 client -- read-only accounts and transactions.""" +"""v1 client -- read-only accounts and transactions, plus status/whoami.""" from __future__ import annotations from typing import Optional from .._base_client import build_transport +from ..models import StatusResponse, WhoAmI from ..transport import ( DEFAULT_BASE_URL, DEFAULT_CONNECT_TIMEOUT, @@ -56,5 +57,11 @@ def transport(self) -> Transport: def last_rate_limit(self) -> object: return self._transport.last_rate_limit + def status(self) -> StatusResponse: + return self.status_resource.status() + + def whoami(self) -> WhoAmI: + return self.status_resource.whoami() + __all__ = ["V1Client"] diff --git a/packages/python/src/tesote_sdk/v1/status.py b/packages/python/src/tesote_sdk/v1/status.py index f10eb03..22b4e3a 100644 --- a/packages/python/src/tesote_sdk/v1/status.py +++ b/packages/python/src/tesote_sdk/v1/status.py @@ -1,21 +1,30 @@ -"""v1 status / whoami stub.""" +"""v1 status / whoami endpoints (`/status`, `/whoami`).""" from __future__ import annotations from typing import Any, Dict +from ..models import StatusResponse, WhoAmI from ..transport import Transport class StatusResource: + """`/status` (no auth) and `/whoami` (auth).""" + def __init__(self, transport: Transport) -> None: self._transport = transport - def status(self) -> Dict[str, Any]: - raise NotImplementedError("v1 status not yet implemented") - - def whoami(self) -> Dict[str, Any]: - raise NotImplementedError("v1 whoami not yet implemented") + def status(self) -> StatusResponse: + """GET /status. Always succeeds.""" + response = self._transport.request("GET", "/status") + body: Dict[str, Any] = response.json if isinstance(response.json, dict) else {} + return StatusResponse.from_dict(body) + + def whoami(self) -> WhoAmI: + """GET /whoami. Requires auth.""" + response = self._transport.request("GET", "/whoami") + body: Dict[str, Any] = response.json if isinstance(response.json, dict) else {} + return WhoAmI.from_dict(body) __all__ = ["StatusResource"] diff --git a/packages/python/src/tesote_sdk/v1/transactions.py b/packages/python/src/tesote_sdk/v1/transactions.py index 6fbdb6c..ca764be 100644 --- a/packages/python/src/tesote_sdk/v1/transactions.py +++ b/packages/python/src/tesote_sdk/v1/transactions.py @@ -1,23 +1,92 @@ -"""v1 transactions resource. Read-only stub.""" +"""v1 transactions resource. Read-only. + +Endpoints: `/v1/accounts/{id}/transactions`, `/v1/transactions/{id}`. +""" from __future__ import annotations -from typing import Any, Dict, List +from typing import Any, Dict, Iterator, Optional +from ..models import Transaction, TransactionList from ..transport import Transport class TransactionsResource: - """Stub: methods raise ``NotImplementedError`` until wired.""" + """v1 transactions: list-for-account (cursor) + show-by-id.""" def __init__(self, transport: Transport) -> None: self._transport = transport - def list_for_account(self, account_id: str) -> List[Dict[str, Any]]: - raise NotImplementedError("v1 transactions.list_for_account not yet implemented") + def list_for_account( + self, + account_id: str, + *, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + scope: Optional[str] = None, + page: Optional[int] = None, + per_page: Optional[int] = None, + transactions_after_id: Optional[str] = None, + transactions_before_id: Optional[str] = None, + cache_ttl: Optional[float] = None, + ) -> TransactionList: + """GET /v1/accounts/{id}/transactions.""" + query: Dict[str, Any] = { + "start_date": start_date, + "end_date": end_date, + "scope": scope, + "page": page, + "per_page": per_page, + "transactions_after_id": transactions_after_id, + "transactions_before_id": transactions_before_id, + } + response = self._transport.request( + "GET", + f"/v1/accounts/{account_id}/transactions", + query=query, + cache_ttl=cache_ttl, + ) + body: Dict[str, Any] = response.json if isinstance(response.json, dict) else {} + return TransactionList.from_dict(body) + + def iter_for_account( + self, + account_id: str, + *, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + scope: Optional[str] = None, + per_page: Optional[int] = None, + ) -> Iterator[Transaction]: + """Iterate every transaction for the account, following the cursor. + + Stops when the server signals ``has_more=False`` or returns an empty page. + """ + after: Optional[str] = None + while True: + page = self.list_for_account( + account_id, + start_date=start_date, + end_date=end_date, + scope=scope, + per_page=per_page, + transactions_after_id=after, + ) + if not page.transactions: + return + yield from page.transactions + cursor = page.pagination + if cursor is None or not cursor.has_more or not cursor.after_id: + return + after = cursor.after_id - def get(self, transaction_id: str) -> Dict[str, Any]: - raise NotImplementedError("v1 transactions.get not yet implemented") + def get(self, transaction_id: str, *, cache_ttl: Optional[float] = None) -> Transaction: + """GET /v1/transactions/{id}.""" + response = self._transport.request( + "GET", f"/v1/transactions/{transaction_id}", cache_ttl=cache_ttl + ) + body: Dict[str, Any] = response.json if isinstance(response.json, dict) else {} + return Transaction.from_dict(body) __all__ = ["TransactionsResource"] diff --git a/packages/python/src/tesote_sdk/v2/_stubs.py b/packages/python/src/tesote_sdk/v2/_stubs.py deleted file mode 100644 index 13bfc8f..0000000 --- a/packages/python/src/tesote_sdk/v2/_stubs.py +++ /dev/null @@ -1,109 +0,0 @@ -"""v2 resources not yet wired beyond their public shape.""" - -from __future__ import annotations - -from typing import Any, Dict - -from ..transport import Transport - - -class _StubResource: - def __init__(self, transport: Transport) -> None: - self._transport = transport - - -class TransactionsResource(_StubResource): - def list_for_account(self, account_id: str) -> Dict[str, Any]: - raise NotImplementedError("v2 transactions.list_for_account not yet implemented") - - def get(self, transaction_id: str) -> Dict[str, Any]: - raise NotImplementedError("v2 transactions.get not yet implemented") - - def export(self, **kwargs: Any) -> Dict[str, Any]: - raise NotImplementedError("v2 transactions.export not yet implemented") - - def sync(self, **kwargs: Any) -> Dict[str, Any]: - raise NotImplementedError("v2 transactions.sync not yet implemented") - - def bulk(self, **kwargs: Any) -> Dict[str, Any]: - raise NotImplementedError("v2 transactions.bulk not yet implemented") - - def search(self, **kwargs: Any) -> Dict[str, Any]: - raise NotImplementedError("v2 transactions.search not yet implemented") - - -class SyncSessionsResource(_StubResource): - def list(self, account_id: str) -> Dict[str, Any]: - raise NotImplementedError("v2 sync_sessions.list not yet implemented") - - def get(self, account_id: str, session_id: str) -> Dict[str, Any]: - raise NotImplementedError("v2 sync_sessions.get not yet implemented") - - -class TransactionOrdersResource(_StubResource): - def list(self, account_id: str) -> Dict[str, Any]: - raise NotImplementedError("v2 transaction_orders.list not yet implemented") - - def get(self, account_id: str, order_id: str) -> Dict[str, Any]: - raise NotImplementedError("v2 transaction_orders.get not yet implemented") - - def create(self, account_id: str, **kwargs: Any) -> Dict[str, Any]: - raise NotImplementedError("v2 transaction_orders.create not yet implemented") - - def submit(self, account_id: str, order_id: str) -> Dict[str, Any]: - raise NotImplementedError("v2 transaction_orders.submit not yet implemented") - - def cancel(self, account_id: str, order_id: str) -> Dict[str, Any]: - raise NotImplementedError("v2 transaction_orders.cancel not yet implemented") - - -class BatchesResource(_StubResource): - def create(self, **kwargs: Any) -> Dict[str, Any]: - raise NotImplementedError("v2 batches.create not yet implemented") - - def get(self, batch_id: str) -> Dict[str, Any]: - raise NotImplementedError("v2 batches.get not yet implemented") - - def approve(self, batch_id: str) -> Dict[str, Any]: - raise NotImplementedError("v2 batches.approve not yet implemented") - - def submit(self, batch_id: str) -> Dict[str, Any]: - raise NotImplementedError("v2 batches.submit not yet implemented") - - def cancel(self, batch_id: str) -> Dict[str, Any]: - raise NotImplementedError("v2 batches.cancel not yet implemented") - - -class PaymentMethodsResource(_StubResource): - def list(self) -> Dict[str, Any]: - raise NotImplementedError("v2 payment_methods.list not yet implemented") - - def get(self, payment_method_id: str) -> Dict[str, Any]: - raise NotImplementedError("v2 payment_methods.get not yet implemented") - - def create(self, **kwargs: Any) -> Dict[str, Any]: - raise NotImplementedError("v2 payment_methods.create not yet implemented") - - def update(self, payment_method_id: str, **kwargs: Any) -> Dict[str, Any]: - raise NotImplementedError("v2 payment_methods.update not yet implemented") - - def delete(self, payment_method_id: str) -> Dict[str, Any]: - raise NotImplementedError("v2 payment_methods.delete not yet implemented") - - -class StatusResource(_StubResource): - def status(self) -> Dict[str, Any]: - raise NotImplementedError("v2 status not yet implemented") - - def whoami(self) -> Dict[str, Any]: - raise NotImplementedError("v2 whoami not yet implemented") - - -__all__ = [ - "TransactionsResource", - "SyncSessionsResource", - "TransactionOrdersResource", - "BatchesResource", - "PaymentMethodsResource", - "StatusResource", -] diff --git a/packages/python/src/tesote_sdk/v2/accounts.py b/packages/python/src/tesote_sdk/v2/accounts.py index 2899fb9..c9759a5 100644 --- a/packages/python/src/tesote_sdk/v2/accounts.py +++ b/packages/python/src/tesote_sdk/v2/accounts.py @@ -1,40 +1,62 @@ -"""v2 accounts resource. Adds ``sync``.""" +"""v2 accounts resource: list, get, sync.""" from __future__ import annotations -from typing import Any, Dict, List, Optional +from typing import Any, Dict, Optional +from ..models import Account, AccountList, AccountSyncStarted from ..transport import Transport class AccountsResource: - """`/v2/accounts` -- list, get implemented; sync stubbed.""" + """`/v2/accounts` -- list, get, sync.""" _PREFIX = "/v2/accounts" def __init__(self, transport: Transport) -> None: self._transport = transport - def list(self, *, cache_ttl: Optional[float] = None) -> List[Dict[str, Any]]: - response = self._transport.request("GET", self._PREFIX, cache_ttl=cache_ttl) - body = response.json - if isinstance(body, list): - return [item for item in body if isinstance(item, dict)] - if isinstance(body, dict) and isinstance(body.get("data"), list): - return [item for item in body["data"] if isinstance(item, dict)] - return [] - - def get(self, account_id: str, *, cache_ttl: Optional[float] = None) -> Dict[str, Any]: + def list( + self, + *, + page: Optional[int] = None, + per_page: Optional[int] = None, + include: Optional[str] = None, + sort: Optional[str] = None, + cache_ttl: Optional[float] = None, + ) -> AccountList: + query: Dict[str, Any] = { + "page": page, + "per_page": per_page, + "include": include, + "sort": sort, + } + response = self._transport.request("GET", self._PREFIX, query=query, cache_ttl=cache_ttl) + body: Dict[str, Any] = response.json if isinstance(response.json, dict) else {} + return AccountList.from_dict(body) + + def get(self, account_id: str, *, cache_ttl: Optional[float] = None) -> Account: response = self._transport.request( "GET", f"{self._PREFIX}/{account_id}", cache_ttl=cache_ttl ) - body = response.json - if isinstance(body, dict): - return body - return {} - - def sync(self, account_id: str, *, idempotency_key: Optional[str] = None) -> Dict[str, Any]: - raise NotImplementedError("v2 accounts.sync not yet implemented") + body: Dict[str, Any] = response.json if isinstance(response.json, dict) else {} + return Account.from_dict(body) + + def sync( + self, + account_id: str, + *, + idempotency_key: Optional[str] = None, + ) -> AccountSyncStarted: + """POST /v2/accounts/{id}/sync. 202 Accepted; idempotency-key header optional.""" + response = self._transport.request( + "POST", + f"{self._PREFIX}/{account_id}/sync", + body={}, + idempotency_key=idempotency_key, + ) + body: Dict[str, Any] = response.json if isinstance(response.json, dict) else {} + return AccountSyncStarted.from_dict(body) __all__ = ["AccountsResource"] diff --git a/packages/python/src/tesote_sdk/v2/batches.py b/packages/python/src/tesote_sdk/v2/batches.py new file mode 100644 index 0000000..675bcc9 --- /dev/null +++ b/packages/python/src/tesote_sdk/v2/batches.py @@ -0,0 +1,109 @@ +"""v2 batches resource: create, show, approve, submit, cancel.""" + +from __future__ import annotations + +from typing import Any, Dict, Mapping, Optional, Sequence + +from ..models import ( + BatchApproveResult, + BatchCancelResult, + BatchCreateResult, + BatchSubmitResult, + BatchSummary, +) +from ..transport import Transport + + +class BatchesResource: + """`/v2/accounts/{id}/batches`.""" + + def __init__(self, transport: Transport) -> None: + self._transport = transport + + def create( + self, + account_id: str, + orders: Sequence[Mapping[str, Any]], + *, + idempotency_key: Optional[str] = None, + ) -> BatchCreateResult: + """POST /v2/accounts/{id}/batches. ``orders`` mirrors the spec's order body shape.""" + body: Dict[str, Any] = {"orders": [dict(o) for o in orders]} + response = self._transport.request( + "POST", + f"/v2/accounts/{account_id}/batches", + body=body, + idempotency_key=idempotency_key, + ) + payload: Dict[str, Any] = response.json if isinstance(response.json, dict) else {} + return BatchCreateResult.from_dict(payload) + + def get( + self, + account_id: str, + batch_id: str, + *, + cache_ttl: Optional[float] = None, + ) -> BatchSummary: + response = self._transport.request( + "GET", + f"/v2/accounts/{account_id}/batches/{batch_id}", + cache_ttl=cache_ttl, + ) + body: Dict[str, Any] = response.json if isinstance(response.json, dict) else {} + return BatchSummary.from_dict(body) + + def approve( + self, + account_id: str, + batch_id: str, + *, + idempotency_key: Optional[str] = None, + ) -> BatchApproveResult: + response = self._transport.request( + "POST", + f"/v2/accounts/{account_id}/batches/{batch_id}/approve", + body={}, + idempotency_key=idempotency_key, + ) + body: Dict[str, Any] = response.json if isinstance(response.json, dict) else {} + return BatchApproveResult.from_dict(body) + + def submit( + self, + account_id: str, + batch_id: str, + *, + token: Optional[str] = None, + idempotency_key: Optional[str] = None, + ) -> BatchSubmitResult: + body: Dict[str, Any] = {} + if token is not None: + body["token"] = token + response = self._transport.request( + "POST", + f"/v2/accounts/{account_id}/batches/{batch_id}/submit", + body=body, + idempotency_key=idempotency_key, + ) + payload: Dict[str, Any] = response.json if isinstance(response.json, dict) else {} + return BatchSubmitResult.from_dict(payload) + + def cancel( + self, + account_id: str, + batch_id: str, + *, + idempotency_key: Optional[str] = None, + ) -> BatchCancelResult: + response = self._transport.request( + "POST", + f"/v2/accounts/{account_id}/batches/{batch_id}/cancel", + body={}, + idempotency_key=idempotency_key, + ) + body: Dict[str, Any] = response.json if isinstance(response.json, dict) else {} + return BatchCancelResult.from_dict(body) + + +__all__ = ["BatchesResource"] diff --git a/packages/python/src/tesote_sdk/v2/client.py b/packages/python/src/tesote_sdk/v2/client.py index ec53166..69e035f 100644 --- a/packages/python/src/tesote_sdk/v2/client.py +++ b/packages/python/src/tesote_sdk/v2/client.py @@ -5,6 +5,7 @@ from typing import Optional from .._base_client import build_transport +from ..models import StatusResponse, WhoAmI from ..transport import ( DEFAULT_BASE_URL, DEFAULT_CONNECT_TIMEOUT, @@ -14,15 +15,13 @@ RetryPolicy, Transport, ) -from ._stubs import ( - BatchesResource, - PaymentMethodsResource, - StatusResource, - SyncSessionsResource, - TransactionOrdersResource, - TransactionsResource, -) from .accounts import AccountsResource +from .batches import BatchesResource +from .payment_methods import PaymentMethodsResource +from .status import StatusResource +from .sync_sessions import SyncSessionsResource +from .transaction_orders import TransactionOrdersResource +from .transactions import TransactionsResource class V2Client: @@ -66,5 +65,11 @@ def transport(self) -> Transport: def last_rate_limit(self) -> object: return self._transport.last_rate_limit + def status(self) -> StatusResponse: + return self.status_resource.status() + + def whoami(self) -> WhoAmI: + return self.status_resource.whoami() + __all__ = ["V2Client"] diff --git a/packages/python/src/tesote_sdk/v2/payment_methods.py b/packages/python/src/tesote_sdk/v2/payment_methods.py new file mode 100644 index 0000000..571accd --- /dev/null +++ b/packages/python/src/tesote_sdk/v2/payment_methods.py @@ -0,0 +1,166 @@ +"""v2 payment_methods resource: list, get, create, update, delete.""" + +from __future__ import annotations + +from typing import Any, Dict, Iterator, Mapping, Optional + +from ..models import PaymentMethod, PaymentMethodList +from ..transport import Transport + + +class PaymentMethodsResource: + """`/v2/payment_methods`.""" + + _PREFIX = "/v2/payment_methods" + + def __init__(self, transport: Transport) -> None: + self._transport = transport + + def list( + self, + *, + limit: Optional[int] = None, + offset: Optional[int] = None, + method_type: Optional[str] = None, + currency: Optional[str] = None, + counterparty_id: Optional[str] = None, + verified: Optional[bool] = None, + cache_ttl: Optional[float] = None, + ) -> PaymentMethodList: + verified_str: Optional[str] = None + if verified is not None: + verified_str = "true" if verified else "false" + query: Dict[str, Any] = { + "limit": limit, + "offset": offset, + "method_type": method_type, + "currency": currency, + "counterparty_id": counterparty_id, + "verified": verified_str, + } + response = self._transport.request( + "GET", self._PREFIX, query=query, cache_ttl=cache_ttl + ) + body: Dict[str, Any] = response.json if isinstance(response.json, dict) else {} + return PaymentMethodList.from_dict(body) + + def iter( + self, + *, + method_type: Optional[str] = None, + currency: Optional[str] = None, + counterparty_id: Optional[str] = None, + verified: Optional[bool] = None, + page_size: int = 50, + ) -> Iterator[PaymentMethod]: + offset = 0 + while True: + page = self.list( + limit=page_size, + offset=offset, + method_type=method_type, + currency=currency, + counterparty_id=counterparty_id, + verified=verified, + ) + if not page.items: + return + yield from page.items + if not page.has_more: + return + offset += len(page.items) + + def get( + self, + payment_method_id: str, + *, + cache_ttl: Optional[float] = None, + ) -> PaymentMethod: + response = self._transport.request( + "GET", f"{self._PREFIX}/{payment_method_id}", cache_ttl=cache_ttl + ) + body: Dict[str, Any] = response.json if isinstance(response.json, dict) else {} + return PaymentMethod.from_dict(body) + + def create( + self, + *, + method_type: str, + currency: str, + details: Mapping[str, Any], + label: Optional[str] = None, + counterparty_id: Optional[str] = None, + counterparty: Optional[Mapping[str, Any]] = None, + idempotency_key: Optional[str] = None, + ) -> PaymentMethod: + """POST /v2/payment_methods. Pass ``counterparty_id`` OR ``counterparty`` (auto-create).""" + pm_body: Dict[str, Any] = { + "method_type": method_type, + "currency": currency, + "details": dict(details), + } + if label is not None: + pm_body["label"] = label + if counterparty_id is not None: + pm_body["counterparty_id"] = counterparty_id + if counterparty is not None: + pm_body["counterparty"] = dict(counterparty) + response = self._transport.request( + "POST", + self._PREFIX, + body={"payment_method": pm_body}, + idempotency_key=idempotency_key, + ) + body: Dict[str, Any] = response.json if isinstance(response.json, dict) else {} + return PaymentMethod.from_dict(body) + + def update( + self, + payment_method_id: str, + *, + method_type: Optional[str] = None, + currency: Optional[str] = None, + label: Optional[str] = None, + details: Optional[Mapping[str, Any]] = None, + counterparty_id: Optional[str] = None, + counterparty: Optional[Mapping[str, Any]] = None, + idempotency_key: Optional[str] = None, + ) -> PaymentMethod: + """PATCH /v2/payment_methods/{id}. Only sends fields the caller passed.""" + pm_body: Dict[str, Any] = {} + if method_type is not None: + pm_body["method_type"] = method_type + if currency is not None: + pm_body["currency"] = currency + if label is not None: + pm_body["label"] = label + if details is not None: + pm_body["details"] = dict(details) + if counterparty_id is not None: + pm_body["counterparty_id"] = counterparty_id + if counterparty is not None: + pm_body["counterparty"] = dict(counterparty) + response = self._transport.request( + "PATCH", + f"{self._PREFIX}/{payment_method_id}", + body={"payment_method": pm_body}, + idempotency_key=idempotency_key, + ) + body: Dict[str, Any] = response.json if isinstance(response.json, dict) else {} + return PaymentMethod.from_dict(body) + + def delete( + self, + payment_method_id: str, + *, + idempotency_key: Optional[str] = None, + ) -> None: + """DELETE /v2/payment_methods/{id}. Returns ``None`` on 204.""" + self._transport.request( + "DELETE", + f"{self._PREFIX}/{payment_method_id}", + idempotency_key=idempotency_key, + ) + + +__all__ = ["PaymentMethodsResource"] diff --git a/packages/python/src/tesote_sdk/v2/status.py b/packages/python/src/tesote_sdk/v2/status.py new file mode 100644 index 0000000..26c848d --- /dev/null +++ b/packages/python/src/tesote_sdk/v2/status.py @@ -0,0 +1,28 @@ +"""v2 status / whoami endpoints (`/v2/status`, `/v2/whoami`).""" + +from __future__ import annotations + +from typing import Any, Dict + +from ..models import StatusResponse, WhoAmI +from ..transport import Transport + + +class StatusResource: + """`/v2/status` (no auth) and `/v2/whoami` (auth).""" + + def __init__(self, transport: Transport) -> None: + self._transport = transport + + def status(self) -> StatusResponse: + response = self._transport.request("GET", "/v2/status") + body: Dict[str, Any] = response.json if isinstance(response.json, dict) else {} + return StatusResponse.from_dict(body) + + def whoami(self) -> WhoAmI: + response = self._transport.request("GET", "/v2/whoami") + body: Dict[str, Any] = response.json if isinstance(response.json, dict) else {} + return WhoAmI.from_dict(body) + + +__all__ = ["StatusResource"] diff --git a/packages/python/src/tesote_sdk/v2/sync_sessions.py b/packages/python/src/tesote_sdk/v2/sync_sessions.py new file mode 100644 index 0000000..a1c2b90 --- /dev/null +++ b/packages/python/src/tesote_sdk/v2/sync_sessions.py @@ -0,0 +1,76 @@ +"""v2 sync_sessions resource: list + get per account.""" + +from __future__ import annotations + +from typing import Any, Dict, Iterator, Optional + +from ..models import SyncSession, SyncSessionList +from ..transport import Transport + + +class SyncSessionsResource: + """`/v2/accounts/{id}/sync_sessions` -- list + show.""" + + def __init__(self, transport: Transport) -> None: + self._transport = transport + + def list( + self, + account_id: str, + *, + limit: Optional[int] = None, + offset: Optional[int] = None, + status: Optional[str] = None, + cache_ttl: Optional[float] = None, + ) -> SyncSessionList: + query: Dict[str, Any] = { + "limit": limit, + "offset": offset, + "status": status, + } + response = self._transport.request( + "GET", + f"/v2/accounts/{account_id}/sync_sessions", + query=query, + cache_ttl=cache_ttl, + ) + body: Dict[str, Any] = response.json if isinstance(response.json, dict) else {} + return SyncSessionList.from_dict(body) + + def iter( + self, + account_id: str, + *, + status: Optional[str] = None, + page_size: int = 50, + ) -> Iterator[SyncSession]: + """Iterate every sync session for the account using offset pagination.""" + offset = 0 + while True: + page = self.list( + account_id, limit=page_size, offset=offset, status=status + ) + if not page.sync_sessions: + return + yield from page.sync_sessions + if not page.has_more: + return + offset += len(page.sync_sessions) + + def get( + self, + account_id: str, + session_id: str, + *, + cache_ttl: Optional[float] = None, + ) -> SyncSession: + response = self._transport.request( + "GET", + f"/v2/accounts/{account_id}/sync_sessions/{session_id}", + cache_ttl=cache_ttl, + ) + body: Dict[str, Any] = response.json if isinstance(response.json, dict) else {} + return SyncSession.from_dict(body) + + +__all__ = ["SyncSessionsResource"] diff --git a/packages/python/src/tesote_sdk/v2/transaction_orders.py b/packages/python/src/tesote_sdk/v2/transaction_orders.py new file mode 100644 index 0000000..fcd1caa --- /dev/null +++ b/packages/python/src/tesote_sdk/v2/transaction_orders.py @@ -0,0 +1,169 @@ +"""v2 transaction_orders resource: list, get, create, submit, cancel.""" + +from __future__ import annotations + +from typing import Any, Dict, Iterator, Mapping, Optional + +from ..models import TransactionOrder, TransactionOrderList +from ..transport import Transport + + +class TransactionOrdersResource: + """`/v2/accounts/{id}/transaction_orders`.""" + + def __init__(self, transport: Transport) -> None: + self._transport = transport + + def list( + self, + account_id: str, + *, + limit: Optional[int] = None, + offset: Optional[int] = None, + status: Optional[str] = None, + created_after: Optional[str] = None, + created_before: Optional[str] = None, + batch_id: Optional[str] = None, + cache_ttl: Optional[float] = None, + ) -> TransactionOrderList: + query: Dict[str, Any] = { + "limit": limit, + "offset": offset, + "status": status, + "created_after": created_after, + "created_before": created_before, + "batch_id": batch_id, + } + response = self._transport.request( + "GET", + f"/v2/accounts/{account_id}/transaction_orders", + query=query, + cache_ttl=cache_ttl, + ) + body: Dict[str, Any] = response.json if isinstance(response.json, dict) else {} + return TransactionOrderList.from_dict(body) + + def iter( + self, + account_id: str, + *, + status: Optional[str] = None, + batch_id: Optional[str] = None, + page_size: int = 50, + ) -> Iterator[TransactionOrder]: + """Iterate every transaction order for the account via offset pagination.""" + offset = 0 + while True: + page = self.list( + account_id, + limit=page_size, + offset=offset, + status=status, + batch_id=batch_id, + ) + if not page.items: + return + yield from page.items + if not page.has_more: + return + offset += len(page.items) + + def get( + self, + account_id: str, + order_id: str, + *, + cache_ttl: Optional[float] = None, + ) -> TransactionOrder: + response = self._transport.request( + "GET", + f"/v2/accounts/{account_id}/transaction_orders/{order_id}", + cache_ttl=cache_ttl, + ) + body: Dict[str, Any] = response.json if isinstance(response.json, dict) else {} + return TransactionOrder.from_dict(body) + + def create( + self, + account_id: str, + *, + amount: str, + currency: str, + description: str, + destination_payment_method_id: Optional[str] = None, + beneficiary: Optional[Mapping[str, Any]] = None, + scheduled_for: Optional[str] = None, + metadata: Optional[Mapping[str, Any]] = None, + idempotency_key: Optional[str] = None, + ) -> TransactionOrder: + """POST /v2/accounts/{id}/transaction_orders. + + Pass ``destination_payment_method_id`` OR a ``beneficiary`` dict (server + creates the on-the-fly PaymentMethod). ``idempotency_key`` is forwarded + both to the request body (for server-side dedupe) and as the transport + ``Idempotency-Key`` header. + """ + order_body: Dict[str, Any] = { + "amount": amount, + "currency": currency, + "description": description, + } + if destination_payment_method_id is not None: + order_body["destination_payment_method_id"] = destination_payment_method_id + if beneficiary is not None: + order_body["beneficiary"] = dict(beneficiary) + if scheduled_for is not None: + order_body["scheduled_for"] = scheduled_for + if metadata is not None: + order_body["metadata"] = dict(metadata) + if idempotency_key is not None: + order_body["idempotency_key"] = idempotency_key + response = self._transport.request( + "POST", + f"/v2/accounts/{account_id}/transaction_orders", + body={"transaction_order": order_body}, + idempotency_key=idempotency_key, + ) + body: Dict[str, Any] = response.json if isinstance(response.json, dict) else {} + return TransactionOrder.from_dict(body) + + def submit( + self, + account_id: str, + order_id: str, + *, + token: Optional[str] = None, + idempotency_key: Optional[str] = None, + ) -> TransactionOrder: + """POST /v2/accounts/{id}/transaction_orders/{order_id}/submit.""" + body: Dict[str, Any] = {} + if token is not None: + body["token"] = token + response = self._transport.request( + "POST", + f"/v2/accounts/{account_id}/transaction_orders/{order_id}/submit", + body=body, + idempotency_key=idempotency_key, + ) + payload: Dict[str, Any] = response.json if isinstance(response.json, dict) else {} + return TransactionOrder.from_dict(payload) + + def cancel( + self, + account_id: str, + order_id: str, + *, + idempotency_key: Optional[str] = None, + ) -> TransactionOrder: + """POST /v2/accounts/{id}/transaction_orders/{order_id}/cancel.""" + response = self._transport.request( + "POST", + f"/v2/accounts/{account_id}/transaction_orders/{order_id}/cancel", + body={}, + idempotency_key=idempotency_key, + ) + body: Dict[str, Any] = response.json if isinstance(response.json, dict) else {} + return TransactionOrder.from_dict(body) + + +__all__ = ["TransactionOrdersResource"] diff --git a/packages/python/src/tesote_sdk/v2/transactions.py b/packages/python/src/tesote_sdk/v2/transactions.py new file mode 100644 index 0000000..113046b --- /dev/null +++ b/packages/python/src/tesote_sdk/v2/transactions.py @@ -0,0 +1,259 @@ +"""v2 transactions resource: per-account list, sync, search, bulk, export, get-by-id.""" + +from __future__ import annotations + +from typing import Any, Dict, Iterator, List, Mapping, Optional + +from ..models import ( + BulkResult, + SearchResult, + SyncDelta, + Transaction, + TransactionList, +) +from ..transport import Transport + + +class TransactionsResource: + """v2 transactions: filtering on /v2/accounts/{id}/transactions, sync, search, bulk, export.""" + + def __init__(self, transport: Transport) -> None: + self._transport = transport + + # -- list / iter for account ---------------------------------------- + + def list_for_account( + self, + account_id: str, + *, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + scope: Optional[str] = None, + page: Optional[int] = None, + per_page: Optional[int] = None, + transactions_after_id: Optional[str] = None, + transactions_before_id: Optional[str] = None, + transaction_date_after: Optional[str] = None, + transaction_date_before: Optional[str] = None, + created_after: Optional[str] = None, + updated_after: Optional[str] = None, + amount_min: Optional[float] = None, + amount_max: Optional[float] = None, + amount: Optional[float] = None, + status: Optional[str] = None, + category_id: Optional[str] = None, + counterparty_id: Optional[str] = None, + q: Optional[str] = None, + type: Optional[str] = None, # noqa: A002 -- matches API param name + reference_code: Optional[str] = None, + cache_ttl: Optional[float] = None, + ) -> TransactionList: + """GET /v2/accounts/{id}/transactions.""" + query: Dict[str, Any] = { + "start_date": start_date, + "end_date": end_date, + "scope": scope, + "page": page, + "per_page": per_page, + "transactions_after_id": transactions_after_id, + "transactions_before_id": transactions_before_id, + "transaction_date_after": transaction_date_after, + "transaction_date_before": transaction_date_before, + "created_after": created_after, + "updated_after": updated_after, + "amount_min": amount_min, + "amount_max": amount_max, + "amount": amount, + "status": status, + "category_id": category_id, + "counterparty_id": counterparty_id, + "q": q, + "type": type, + "reference_code": reference_code, + } + response = self._transport.request( + "GET", + f"/v2/accounts/{account_id}/transactions", + query=query, + cache_ttl=cache_ttl, + ) + body: Dict[str, Any] = response.json if isinstance(response.json, dict) else {} + return TransactionList.from_dict(body) + + def iter_for_account( + self, + account_id: str, + *, + per_page: Optional[int] = None, + **filters: Any, + ) -> Iterator[Transaction]: + """Iterate every transaction for the account, following the cursor.""" + after: Optional[str] = None + while True: + page = self.list_for_account( + account_id, + per_page=per_page, + transactions_after_id=after, + **filters, + ) + if not page.transactions: + return + yield from page.transactions + cursor = page.pagination + if cursor is None or not cursor.has_more or not cursor.after_id: + return + after = cursor.after_id + + # -- export --------------------------------------------------------- + + def export( + self, + account_id: str, + *, + format: str = "csv", # noqa: A002 -- matches API param + start_date: Optional[str] = None, + end_date: Optional[str] = None, + **filters: Any, + ) -> str: + """GET /v2/accounts/{id}/transactions/export. Returns the file body as text.""" + query: Dict[str, Any] = { + "format": format, + "start_date": start_date, + "end_date": end_date, + } + for k, v in filters.items(): + query[k] = v + response = self._transport.request( + "GET", + f"/v2/accounts/{account_id}/transactions/export", + query=query, + ) + return response.body + + # -- get by id ------------------------------------------------------ + + def get(self, transaction_id: str, *, cache_ttl: Optional[float] = None) -> Transaction: + """GET /v2/transactions/{id} -- v1 schema (not SyncTransaction).""" + response = self._transport.request( + "GET", f"/v2/transactions/{transaction_id}", cache_ttl=cache_ttl + ) + body: Dict[str, Any] = response.json if isinstance(response.json, dict) else {} + return Transaction.from_dict(body) + + # -- sync (per-account + legacy) ------------------------------------ + + def sync( + self, + account_id: str, + *, + count: Optional[int] = None, + cursor: Optional[str] = None, + include_running_balance: Optional[bool] = None, + idempotency_key: Optional[str] = None, + ) -> SyncDelta: + """POST /v2/accounts/{id}/transactions/sync.""" + body = self._build_sync_body(count, cursor, include_running_balance) + response = self._transport.request( + "POST", + f"/v2/accounts/{account_id}/transactions/sync", + body=body, + idempotency_key=idempotency_key, + ) + payload: Dict[str, Any] = response.json if isinstance(response.json, dict) else {} + return SyncDelta.from_dict(payload) + + def sync_legacy( + self, + *, + account_id: Optional[str] = None, + count: Optional[int] = None, + cursor: Optional[str] = None, + include_running_balance: Optional[bool] = None, + idempotency_key: Optional[str] = None, + ) -> SyncDelta: + """POST /v2/transactions/sync -- legacy non-nested route.""" + body = self._build_sync_body(count, cursor, include_running_balance) + if account_id is not None: + body["account_id"] = account_id + response = self._transport.request( + "POST", + "/v2/transactions/sync", + body=body, + idempotency_key=idempotency_key, + ) + payload: Dict[str, Any] = response.json if isinstance(response.json, dict) else {} + return SyncDelta.from_dict(payload) + + @staticmethod + def _build_sync_body( + count: Optional[int], + cursor: Optional[str], + include_running_balance: Optional[bool], + ) -> Dict[str, Any]: + body: Dict[str, Any] = {} + if count is not None: + body["count"] = count + if cursor is not None: + body["cursor"] = cursor + if include_running_balance is not None: + body["options"] = {"include_running_balance": bool(include_running_balance)} + return body + + # -- bulk ----------------------------------------------------------- + + def bulk( + self, + account_ids: List[str], + *, + page: Optional[int] = None, + per_page: Optional[int] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + idempotency_key: Optional[str] = None, + ) -> BulkResult: + """POST /v2/transactions/bulk -- max 100 accounts.""" + body: Dict[str, Any] = {"account_ids": list(account_ids)} + if page is not None: + body["page"] = page + if per_page is not None: + body["per_page"] = per_page + if limit is not None: + body["limit"] = limit + if offset is not None: + body["offset"] = offset + response = self._transport.request( + "POST", "/v2/transactions/bulk", body=body, idempotency_key=idempotency_key + ) + payload: Dict[str, Any] = response.json if isinstance(response.json, dict) else {} + return BulkResult.from_dict(payload) + + # -- search --------------------------------------------------------- + + def search( + self, + q: str, + *, + account_id: Optional[str] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + filters: Optional[Mapping[str, Any]] = None, + cache_ttl: Optional[float] = None, + ) -> SearchResult: + """GET /v2/transactions/search. ``q`` is required.""" + query: Dict[str, Any] = { + "q": q, + "account_id": account_id, + "limit": limit, + "offset": offset, + } + if filters: + for k, v in filters.items(): + query[k] = v + response = self._transport.request( + "GET", "/v2/transactions/search", query=query, cache_ttl=cache_ttl + ) + body: Dict[str, Any] = response.json if isinstance(response.json, dict) else {} + return SearchResult.from_dict(body) + + +__all__ = ["TransactionsResource"] diff --git a/packages/python/tests/_helpers.py b/packages/python/tests/_helpers.py new file mode 100644 index 0000000..645fdcc --- /dev/null +++ b/packages/python/tests/_helpers.py @@ -0,0 +1,19 @@ +"""Shared helpers for resource tests.""" + +from __future__ import annotations + +from typing import Any, Sequence + +from tesote_sdk.transport import RetryPolicy, Transport + +from .conftest import ScriptedOpener + + +def make_transport(responses: Sequence[Any]) -> tuple[Transport, ScriptedOpener]: + opener = ScriptedOpener(list(responses)) + transport = Transport( + api_key="sk_test_abcdef1234", + retry_policy=RetryPolicy(max_attempts=3, base_delay=0.0, max_delay=0.0), + opener=opener, + ) + return transport, opener diff --git a/packages/python/tests/test_v1_accounts.py b/packages/python/tests/test_v1_accounts.py new file mode 100644 index 0000000..2688063 --- /dev/null +++ b/packages/python/tests/test_v1_accounts.py @@ -0,0 +1,82 @@ +"""v1 accounts resource.""" + +from __future__ import annotations + +import pytest + +from tesote_sdk.errors import AccountNotFoundError, UnauthorizedError +from tesote_sdk.v1.accounts import AccountsResource + +from ._helpers import make_transport +from .conftest import http_error, ok_response + +_ACCOUNT = { + "id": "acct_1", + "name": "Operating", + "data": { + "masked_account_number": "****1234", + "currency": "VES", + "balance_cents": "1000000", + }, + "bank": {"name": "Banco X"}, + "legal_entity": {"id": "le_1", "legal_name": "Acme SA"}, + "tesote_created_at": "2026-01-01T00:00:00Z", + "tesote_updated_at": "2026-01-02T00:00:00Z", +} + + +def test_list_returns_typed_account_list() -> None: + payload = { + "total": 1, + "accounts": [_ACCOUNT], + "pagination": { + "current_page": 1, + "per_page": 50, + "total_pages": 1, + "total_count": 1, + }, + } + transport, opener = make_transport([ok_response(payload)]) + result = AccountsResource(transport).list(page=1, per_page=50, sort="name") + assert result.total == 1 + assert len(result.accounts) == 1 + assert result.accounts[0].id == "acct_1" + assert result.accounts[0].bank.name == "Banco X" + assert result.accounts[0].data.currency == "VES" + assert result.pagination is not None + assert result.pagination.current_page == 1 + url = opener.calls[0]["url"] + assert "page=1" in url + assert "per_page=50" in url + assert "sort=name" in url + + +def test_list_drops_none_query_params() -> None: + transport, opener = make_transport([ok_response({"total": 0, "accounts": []})]) + AccountsResource(transport).list() + # url has no query string at all + assert "?" not in opener.calls[0]["url"] + + +def test_get_returns_account_model() -> None: + transport, _ = make_transport([ok_response(_ACCOUNT)]) + account = AccountsResource(transport).get("acct_1") + assert account.id == "acct_1" + assert account.name == "Operating" + assert account.legal_entity.legal_name == "Acme SA" + + +def test_get_404_raises_account_not_found() -> None: + transport, _ = make_transport( + [http_error(404, {}, {"error": "missing", "error_code": "ACCOUNT_NOT_FOUND"})] + ) + with pytest.raises(AccountNotFoundError): + AccountsResource(transport).get("acct_missing") + + +def test_list_unauthorized_raises_typed_error() -> None: + transport, _ = make_transport( + [http_error(401, {}, {"error": "no key", "error_code": "UNAUTHORIZED"})] + ) + with pytest.raises(UnauthorizedError): + AccountsResource(transport).list() diff --git a/packages/python/tests/test_v1_status.py b/packages/python/tests/test_v1_status.py new file mode 100644 index 0000000..5b058d6 --- /dev/null +++ b/packages/python/tests/test_v1_status.py @@ -0,0 +1,39 @@ +"""v1 status / whoami resource.""" + +from __future__ import annotations + +import pytest + +from tesote_sdk.errors import UnauthorizedError +from tesote_sdk.v1.status import StatusResource + +from ._helpers import make_transport +from .conftest import http_error, ok_response + + +def test_status_returns_typed_response() -> None: + transport, opener = make_transport([ok_response({"status": "ok", "authenticated": False})]) + res = StatusResource(transport).status() + assert res.status == "ok" + assert res.authenticated is False + assert opener.calls[0]["url"].endswith("/status") + assert opener.calls[0]["method"] == "GET" + + +def test_whoami_returns_client_block() -> None: + transport, _ = make_transport( + [ok_response({"client": {"id": "cli_1", "name": "Acme", "type": "workspace"}})] + ) + res = StatusResource(transport).whoami() + assert res.client is not None + assert res.client.id == "cli_1" + assert res.client.name == "Acme" + assert res.client.type == "workspace" + + +def test_whoami_unauthorized_raises_typed_error() -> None: + transport, _ = make_transport( + [http_error(401, {}, {"error": "bad", "error_code": "UNAUTHORIZED"})] + ) + with pytest.raises(UnauthorizedError): + StatusResource(transport).whoami() diff --git a/packages/python/tests/test_v1_transactions.py b/packages/python/tests/test_v1_transactions.py new file mode 100644 index 0000000..cd6bd0d --- /dev/null +++ b/packages/python/tests/test_v1_transactions.py @@ -0,0 +1,125 @@ +"""v1 transactions resource.""" + +from __future__ import annotations + +import pytest + +from tesote_sdk.errors import ( + InvalidDateRangeError, + TransactionNotFoundError, +) +from tesote_sdk.v1.transactions import TransactionsResource + +from ._helpers import make_transport +from .conftest import http_error, ok_response + + +def _txn(txn_id: str) -> dict: + return { + "id": txn_id, + "status": "posted", + "data": { + "amount_cents": 1000, + "currency": "VES", + "description": f"txn {txn_id}", + "transaction_date": "2026-04-01", + }, + "tesote_imported_at": "2026-04-01T00:00:00Z", + "tesote_updated_at": "2026-04-01T00:00:00Z", + "transaction_categories": [{"name": "groceries"}], + "counterparty": {"name": "Acme"}, + } + + +def test_list_for_account_returns_cursor_paginated_list() -> None: + payload = { + "total": 2, + "transactions": [_txn("t_1"), _txn("t_2")], + "pagination": { + "has_more": True, + "per_page": 50, + "after_id": "t_2", + "before_id": "t_1", + }, + } + transport, opener = make_transport([ok_response(payload)]) + result = TransactionsResource(transport).list_for_account( + "acct_1", per_page=50, transactions_after_id="prev" + ) + assert len(result.transactions) == 2 + assert result.transactions[0].counterparty is not None + assert result.transactions[0].counterparty.name == "Acme" + assert result.transactions[0].transaction_categories[0].name == "groceries" + assert result.pagination is not None + assert result.pagination.has_more is True + assert result.pagination.after_id == "t_2" + url = opener.calls[0]["url"] + assert "transactions_after_id=prev" in url + assert "per_page=50" in url + + +def test_iter_for_account_follows_cursor_until_has_more_false() -> None: + page1 = ok_response( + { + "transactions": [_txn("t_1"), _txn("t_2")], + "pagination": {"has_more": True, "after_id": "t_2", "before_id": "t_1"}, + } + ) + page2 = ok_response( + { + "transactions": [_txn("t_3")], + "pagination": {"has_more": False, "after_id": "t_3", "before_id": "t_3"}, + } + ) + transport, opener = make_transport([page1, page2]) + txns = list(TransactionsResource(transport).iter_for_account("acct_1")) + assert [t.id for t in txns] == ["t_1", "t_2", "t_3"] + assert len(opener.calls) == 2 + # second call uses after_id from first + assert "transactions_after_id=t_2" in opener.calls[1]["url"] + + +def test_iter_stops_when_first_page_empty() -> None: + transport, opener = make_transport( + [ok_response({"transactions": [], "pagination": {"has_more": False}})] + ) + txns = list(TransactionsResource(transport).iter_for_account("acct_1")) + assert txns == [] + assert len(opener.calls) == 1 + + +def test_get_returns_transaction_model() -> None: + transport, _ = make_transport([ok_response(_txn("t_1"))]) + txn = TransactionsResource(transport).get("t_1") + assert txn.id == "t_1" + assert txn.data.amount_cents == 1000 + + +def test_get_404_raises_transaction_not_found() -> None: + transport, _ = make_transport( + [ + http_error( + 404, + {}, + {"error": "missing", "error_code": "TRANSACTION_NOT_FOUND"}, + ) + ] + ) + with pytest.raises(TransactionNotFoundError): + TransactionsResource(transport).get("t_missing") + + +def test_invalid_date_range_raises_typed_error() -> None: + transport, _ = make_transport( + [ + http_error( + 422, + {}, + {"error": "bad dates", "error_code": "INVALID_DATE_RANGE"}, + ) + ] + ) + with pytest.raises(InvalidDateRangeError): + TransactionsResource(transport).list_for_account( + "acct_1", start_date="bogus", end_date="bogus" + ) diff --git a/packages/python/tests/test_v2_accounts.py b/packages/python/tests/test_v2_accounts.py new file mode 100644 index 0000000..1808a8d --- /dev/null +++ b/packages/python/tests/test_v2_accounts.py @@ -0,0 +1,174 @@ +"""v2 accounts resource: list, get, sync.""" + +from __future__ import annotations + +import pytest + +from tesote_sdk.errors import ( + AccountNotFoundError, + BankUnderMaintenanceError, + SyncInProgressError, + SyncRateLimitExceededError, + UnprocessableContentError, +) +from tesote_sdk.v2.accounts import AccountsResource + +from ._helpers import make_transport +from .conftest import http_error, ok_response + + +def test_list_returns_account_list() -> None: + transport, _ = make_transport( + [ + ok_response( + { + "total": 0, + "accounts": [], + "pagination": { + "current_page": 1, + "per_page": 50, + "total_pages": 1, + "total_count": 0, + }, + } + ) + ] + ) + result = AccountsResource(transport).list(page=1) + assert result.total == 0 + assert result.pagination is not None + assert result.pagination.current_page == 1 + + +def test_get_uses_v2_prefix() -> None: + transport, opener = make_transport( + [ + ok_response( + { + "id": "acct_1", + "name": "Op", + "data": {}, + "bank": {}, + "legal_entity": {}, + } + ) + ] + ) + AccountsResource(transport).get("acct_1") + assert opener.calls[0]["url"].endswith("/v2/accounts/acct_1") + + +def test_sync_returns_started_response_and_sets_idempotency_header() -> None: + transport, opener = make_transport( + [ + ok_response( + { + "message": "Sync started", + "sync_session_id": "ss_1", + "status": "pending", + "started_at": "2026-04-28T19:21:00Z", + }, + status=202, + ) + ] + ) + res = AccountsResource(transport).sync("acct_1", idempotency_key="my-key") + assert res.sync_session_id == "ss_1" + assert res.status == "pending" + headers = dict(opener.calls[0]["headers"]) + assert headers["Idempotency-key"] == "my-key" + # Content-Type required because POST body sent + assert headers["Content-type"] == "application/json" + + +def test_sync_auto_generates_idempotency_key_when_omitted() -> None: + transport, opener = make_transport( + [ + ok_response( + { + "message": "Sync started", + "sync_session_id": "ss_1", + "status": "pending", + "started_at": "2026-04-28T19:21:00Z", + }, + status=202, + ) + ] + ) + AccountsResource(transport).sync("acct_1") + headers = dict(opener.calls[0]["headers"]) + assert "Idempotency-key" in headers + assert len(headers["Idempotency-key"]) >= 16 + + +def test_sync_409_raises_sync_in_progress() -> None: + transport, _ = make_transport( + [ + http_error( + 409, + {}, + {"error": "in flight", "error_code": "SYNC_IN_PROGRESS"}, + ) + ] + ) + with pytest.raises(SyncInProgressError): + AccountsResource(transport).sync("acct_1") + + +def test_sync_429_raises_sync_rate_limit_exceeded() -> None: + transport, _ = make_transport( + [ + http_error( + 429, + {"Retry-After": "0"}, + {"error": "wait", "error_code": "SYNC_RATE_LIMIT_EXCEEDED"}, + ), + http_error( + 429, + {"Retry-After": "0"}, + {"error": "wait", "error_code": "SYNC_RATE_LIMIT_EXCEEDED"}, + ), + http_error( + 429, + {"Retry-After": "0"}, + {"error": "wait", "error_code": "SYNC_RATE_LIMIT_EXCEEDED"}, + ), + ] + ) + with pytest.raises(SyncRateLimitExceededError): + AccountsResource(transport).sync("acct_1") + + +def test_sync_503_bank_under_maintenance() -> None: + transport, _ = make_transport( + [ + http_error(503, {}, {"error": "down", "error_code": "BANK_UNDER_MAINTENANCE"}) + for _ in range(3) + ] + ) + with pytest.raises(BankUnderMaintenanceError): + AccountsResource(transport).sync("acct_1") + + +def test_sync_404_raises_account_not_found() -> None: + transport, _ = make_transport( + [http_error(404, {}, {"error": "no", "error_code": "ACCOUNT_NOT_FOUND"})] + ) + with pytest.raises(AccountNotFoundError): + AccountsResource(transport).sync("acct_missing") + + +def test_415_when_content_type_missing_maps_to_unprocessable_content() -> None: + """Server returns 415 when POST/PATCH lack Content-Type. Transport always sets it, + but we verify the typed-error mapping path here using a forced 415 response.""" + transport, _ = make_transport( + [ + http_error( + 415, + {}, + {"error": "need json", "error_code": "UNPROCESSABLE_CONTENT"}, + ) + ] + ) + with pytest.raises(UnprocessableContentError): + AccountsResource(transport).sync("acct_1") diff --git a/packages/python/tests/test_v2_batches.py b/packages/python/tests/test_v2_batches.py new file mode 100644 index 0000000..1d6a6b9 --- /dev/null +++ b/packages/python/tests/test_v2_batches.py @@ -0,0 +1,139 @@ +"""v2 batches resource: create, get, approve, submit, cancel.""" + +from __future__ import annotations + +import json + +import pytest + +from tesote_sdk.errors import ( + BatchNotFoundError, + BatchValidationError, + InvalidOrderStateError, +) +from tesote_sdk.v2.batches import BatchesResource + +from ._helpers import make_transport +from .conftest import http_error, ok_response + + +def _order(oid: str = "o1", status: str = "draft") -> dict: + return { + "id": oid, + "status": status, + "amount": 50.0, + "currency": "VES", + "description": "x", + "created_at": "2026-04-01T00:00:00Z", + "updated_at": "2026-04-01T00:00:00Z", + } + + +def test_create_wraps_orders_in_body_and_returns_batch_create_result() -> None: + transport, opener = make_transport( + [ + ok_response( + { + "batch_id": "b_1", + "orders": [_order("o1"), _order("o2")], + "errors": [], + }, + status=201, + ) + ] + ) + res = BatchesResource(transport).create( + "acct_1", + [ + {"amount": "10.00", "currency": "VES", "description": "a"}, + {"amount": "20.00", "currency": "VES", "description": "b"}, + ], + idempotency_key="bk-1", + ) + assert res.batch_id == "b_1" + assert len(res.orders) == 2 + body = json.loads(opener.calls[0]["body"].decode("utf-8")) + assert "orders" in body and len(body["orders"]) == 2 + headers = dict(opener.calls[0]["headers"]) + assert headers["Idempotency-key"] == "bk-1" + + +def test_create_batch_validation_error() -> None: + transport, _ = make_transport( + [ + http_error( + 400, + {}, + {"error": "bad batch", "error_code": "BATCH_VALIDATION_ERROR"}, + ) + ] + ) + with pytest.raises(BatchValidationError): + BatchesResource(transport).create("acct_1", []) + + +def test_get_returns_summary_with_statuses() -> None: + transport, _ = make_transport( + [ + ok_response( + { + "batch_id": "b_1", + "total_orders": 5, + "total_amount_cents": 50000, + "amount_currency": "VES", + "statuses": {"draft": 3, "approved": 2}, + "batch_status": "mixed", + "created_at": "2026-04-01T00:00:00Z", + "orders": [_order("o1"), _order("o2", "approved")], + } + ) + ] + ) + summary = BatchesResource(transport).get("acct_1", "b_1") + assert summary.batch_id == "b_1" + assert summary.statuses == {"draft": 3, "approved": 2} + assert summary.batch_status == "mixed" + assert len(summary.orders) == 2 + + +def test_get_404_batch_not_found() -> None: + transport, _ = make_transport( + [http_error(404, {}, {"error": "missing", "error_code": "BATCH_NOT_FOUND"})] + ) + with pytest.raises(BatchNotFoundError): + BatchesResource(transport).get("acct_1", "missing") + + +def test_approve_returns_counts() -> None: + transport, opener = make_transport([ok_response({"approved": 5, "failed": 0})]) + res = BatchesResource(transport).approve("acct_1", "b_1") + assert res.approved == 5 + assert res.failed == 0 + assert opener.calls[0]["url"].endswith("/v2/accounts/acct_1/batches/b_1/approve") + + +def test_approve_invalid_order_state() -> None: + transport, _ = make_transport( + [http_error(409, {}, {"error": "bad state", "error_code": "INVALID_ORDER_STATE"})] + ) + with pytest.raises(InvalidOrderStateError): + BatchesResource(transport).approve("acct_1", "b_1") + + +def test_submit_with_token() -> None: + transport, opener = make_transport([ok_response({"enqueued": 5, "failed": 0})]) + res = BatchesResource(transport).submit("acct_1", "b_1", token="otp") + assert res.enqueued == 5 + body = json.loads(opener.calls[0]["body"].decode("utf-8")) + assert body == {"token": "otp"} + + +def test_cancel_returns_counts_and_skipped() -> None: + transport, opener = make_transport( + [ok_response({"cancelled": 4, "skipped": 1, "errors": []})] + ) + res = BatchesResource(transport).cancel("acct_1", "b_1", idempotency_key="bk-cancel") + assert res.cancelled == 4 + assert res.skipped == 1 + headers = dict(opener.calls[0]["headers"]) + assert headers["Idempotency-key"] == "bk-cancel" diff --git a/packages/python/tests/test_v2_payment_methods.py b/packages/python/tests/test_v2_payment_methods.py new file mode 100644 index 0000000..2cc67de --- /dev/null +++ b/packages/python/tests/test_v2_payment_methods.py @@ -0,0 +1,148 @@ +"""v2 payment_methods resource: list, get, create, update, delete.""" + +from __future__ import annotations + +import json + +import pytest + +from tesote_sdk.errors import ( + PaymentMethodNotFoundError, + UnprocessableContentError, + ValidationError, +) +from tesote_sdk.v2.payment_methods import PaymentMethodsResource + +from ._helpers import make_transport +from .conftest import http_error, ok_response + + +def _pm(pid: str = "pm_1") -> dict: + return { + "id": pid, + "method_type": "bank_account", + "currency": "VES", + "label": "main", + "details": { + "bank_code": "0102", + "account_number": "1234", + "holder_name": "Acme", + }, + "verified": True, + "verified_at": "2026-01-01T00:00:00Z", + "counterparty": {"id": "cp_1", "name": "Vendor"}, + "tesote_account": None, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + } + + +def test_list_serializes_filters_including_verified_bool_to_string() -> None: + transport, opener = make_transport( + [ok_response({"items": [_pm()], "has_more": False, "limit": 50, "offset": 0})] + ) + PaymentMethodsResource(transport).list( + method_type="bank_account", + currency="VES", + counterparty_id="cp_1", + verified=True, + ) + url = opener.calls[0]["url"] + assert "method_type=bank_account" in url + assert "currency=VES" in url + assert "counterparty_id=cp_1" in url + assert "verified=true" in url + + +def test_iter_follows_offset() -> None: + p1 = ok_response( + {"items": [_pm("pm_1"), _pm("pm_2")], "has_more": True, "limit": 2, "offset": 0} + ) + p2 = ok_response( + {"items": [_pm("pm_3")], "has_more": False, "limit": 2, "offset": 2} + ) + transport, opener = make_transport([p1, p2]) + items = list(PaymentMethodsResource(transport).iter(page_size=2)) + assert [p.id for p in items] == ["pm_1", "pm_2", "pm_3"] + assert "offset=2" in opener.calls[1]["url"] + + +def test_get_404_payment_method_not_found() -> None: + transport, _ = make_transport( + [ + http_error( + 404, {}, {"error": "missing", "error_code": "PAYMENT_METHOD_NOT_FOUND"} + ) + ] + ) + with pytest.raises(PaymentMethodNotFoundError): + PaymentMethodsResource(transport).get("pm_missing") + + +def test_create_wraps_body_with_payment_method_key_and_sets_idempotency() -> None: + transport, opener = make_transport([ok_response(_pm(), status=201)]) + PaymentMethodsResource(transport).create( + method_type="bank_account", + currency="VES", + details={"bank_code": "0102", "account_number": "1234", "holder_name": "Acme"}, + label="main", + counterparty={"name": "Vendor"}, + idempotency_key="pm-key", + ) + body = json.loads(opener.calls[0]["body"].decode("utf-8")) + assert body["payment_method"]["method_type"] == "bank_account" + assert body["payment_method"]["counterparty"] == {"name": "Vendor"} + headers = dict(opener.calls[0]["headers"]) + assert headers["Idempotency-key"] == "pm-key" + assert headers["Content-type"] == "application/json" + + +def test_create_validation_error() -> None: + transport, _ = make_transport( + [http_error(400, {}, {"error": "bad", "error_code": "VALIDATION_ERROR"})] + ) + with pytest.raises(ValidationError): + PaymentMethodsResource(transport).create( + method_type="bank_account", currency="VES", details={} + ) + + +def test_update_only_sends_provided_fields() -> None: + transport, opener = make_transport([ok_response(_pm())]) + PaymentMethodsResource(transport).update("pm_1", label="renamed") + assert opener.calls[0]["method"] == "PATCH" + body = json.loads(opener.calls[0]["body"].decode("utf-8")) + assert body == {"payment_method": {"label": "renamed"}} + + +def test_delete_204_returns_none_and_idempotency_propagates() -> None: + transport, opener = make_transport([ok_response(b"", status=204)]) + PaymentMethodsResource(transport).delete("pm_1", idempotency_key="del-key") + assert opener.calls[0]["method"] == "DELETE" + headers = dict(opener.calls[0]["headers"]) + assert headers["Idempotency-key"] == "del-key" + + +def test_delete_409_in_use_maps_to_validation_error() -> None: + """Spec says 409 VALIDATION_ERROR when payment method has active orders.""" + transport, _ = make_transport( + [http_error(409, {}, {"error": "in use", "error_code": "VALIDATION_ERROR"})] + ) + with pytest.raises(ValidationError): + PaymentMethodsResource(transport).delete("pm_1") + + +def test_415_when_content_type_missing_maps_to_unprocessable() -> None: + transport, _ = make_transport( + [ + http_error( + 415, + {}, + {"error": "need json", "error_code": "UNPROCESSABLE_CONTENT"}, + ) + ] + ) + with pytest.raises(UnprocessableContentError): + PaymentMethodsResource(transport).create( + method_type="bank_account", currency="VES", details={} + ) diff --git a/packages/python/tests/test_v2_status.py b/packages/python/tests/test_v2_status.py new file mode 100644 index 0000000..7424fcc --- /dev/null +++ b/packages/python/tests/test_v2_status.py @@ -0,0 +1,25 @@ +"""v2 status / whoami resource.""" + +from __future__ import annotations + +from tesote_sdk.v2.status import StatusResource + +from ._helpers import make_transport +from .conftest import ok_response + + +def test_v2_status_uses_v2_path() -> None: + transport, opener = make_transport( + [ok_response({"status": "ok", "authenticated": False})] + ) + res = StatusResource(transport).status() + assert res.status == "ok" + assert opener.calls[0]["url"].endswith("/v2/status") + + +def test_v2_whoami_uses_v2_path() -> None: + transport, opener = make_transport( + [ok_response({"client": {"id": "cli", "name": "x", "type": "user"}})] + ) + StatusResource(transport).whoami() + assert opener.calls[0]["url"].endswith("/v2/whoami") diff --git a/packages/python/tests/test_v2_sync_sessions.py b/packages/python/tests/test_v2_sync_sessions.py new file mode 100644 index 0000000..1bac5af --- /dev/null +++ b/packages/python/tests/test_v2_sync_sessions.py @@ -0,0 +1,120 @@ +"""v2 sync_sessions resource: list, iter, get.""" + +from __future__ import annotations + +import pytest + +from tesote_sdk.errors import ( + BankConnectionNotFoundError, + SyncSessionNotFoundError, +) +from tesote_sdk.v2.sync_sessions import SyncSessionsResource + +from ._helpers import make_transport +from .conftest import http_error, ok_response + + +def _session(sid: str, status: str = "completed") -> dict: + return { + "id": sid, + "status": status, + "started_at": "2026-04-01T00:00:00Z", + "completed_at": "2026-04-01T00:00:30Z", + "transactions_synced": 10, + "accounts_count": 1, + } + + +def test_list_returns_paginated_sessions() -> None: + transport, opener = make_transport( + [ + ok_response( + { + "sync_sessions": [_session("ss_1"), _session("ss_2", "failed")], + "limit": 50, + "offset": 0, + "has_more": False, + } + ) + ] + ) + result = SyncSessionsResource(transport).list("acct_1", status="completed") + assert len(result.sync_sessions) == 2 + assert result.has_more is False + assert "status=completed" in opener.calls[0]["url"] + + +def test_iter_follows_offset_pagination() -> None: + p1 = ok_response( + { + "sync_sessions": [_session("ss_1"), _session("ss_2")], + "limit": 2, + "offset": 0, + "has_more": True, + } + ) + p2 = ok_response( + { + "sync_sessions": [_session("ss_3")], + "limit": 2, + "offset": 2, + "has_more": False, + } + ) + transport, opener = make_transport([p1, p2]) + sessions = list( + SyncSessionsResource(transport).iter("acct_1", page_size=2) + ) + assert [s.id for s in sessions] == ["ss_1", "ss_2", "ss_3"] + assert "offset=2" in opener.calls[1]["url"] + + +def test_get_returns_typed_session_with_error_block_when_failed() -> None: + transport, _ = make_transport( + [ + ok_response( + { + **_session("ss_1", "failed"), + "error": {"type": "BankError", "message": "down"}, + "performance": { + "total_duration": 1.5, + "complexity_score": 0.8, + "sync_speed_score": 0.9, + }, + } + ) + ] + ) + session = SyncSessionsResource(transport).get("acct_1", "ss_1") + assert session.error is not None + assert session.error.message == "down" + assert session.performance is not None + assert session.performance.total_duration == 1.5 + + +def test_get_404_session_not_found() -> None: + transport, _ = make_transport( + [ + http_error( + 404, + {}, + {"error": "missing", "error_code": "SYNC_SESSION_NOT_FOUND"}, + ) + ] + ) + with pytest.raises(SyncSessionNotFoundError): + SyncSessionsResource(transport).get("acct_1", "ss_missing") + + +def test_list_404_bank_connection_not_found() -> None: + transport, _ = make_transport( + [ + http_error( + 404, + {}, + {"error": "no bank link", "error_code": "BANK_CONNECTION_NOT_FOUND"}, + ) + ] + ) + with pytest.raises(BankConnectionNotFoundError): + SyncSessionsResource(transport).list("acct_1") diff --git a/packages/python/tests/test_v2_transaction_orders.py b/packages/python/tests/test_v2_transaction_orders.py new file mode 100644 index 0000000..737e7f9 --- /dev/null +++ b/packages/python/tests/test_v2_transaction_orders.py @@ -0,0 +1,182 @@ +"""v2 transaction_orders resource: list, get, create, submit, cancel.""" + +from __future__ import annotations + +import json + +import pytest + +from tesote_sdk.errors import ( + InvalidOrderStateError, + TransactionOrderNotFoundError, + ValidationError, +) +from tesote_sdk.v2.transaction_orders import TransactionOrdersResource + +from ._helpers import make_transport +from .conftest import http_error, ok_response + + +def _order(oid: str, status: str = "draft") -> dict: + return { + "id": oid, + "status": status, + "amount": 100.0, + "currency": "VES", + "description": "test", + "source_account": {"id": "acct_1", "name": "Op", "payment_method_id": "pm_src"}, + "destination": { + "payment_method_id": "pm_dst", + "counterparty_id": "cp_1", + "counterparty_name": "Vendor", + }, + "fee": {"amount": 1.5, "currency": "VES"}, + "created_at": "2026-04-01T00:00:00Z", + "updated_at": "2026-04-01T00:00:00Z", + } + + +def test_list_returns_paginated_orders() -> None: + transport, opener = make_transport( + [ + ok_response( + { + "items": [_order("o1"), _order("o2", "approved")], + "has_more": False, + "limit": 50, + "offset": 0, + } + ) + ] + ) + result = TransactionOrdersResource(transport).list( + "acct_1", limit=50, status="draft", batch_id="b_1" + ) + assert len(result.items) == 2 + assert result.items[1].status == "approved" + url = opener.calls[0]["url"] + assert "status=draft" in url + assert "batch_id=b_1" in url + + +def test_iter_follows_offset() -> None: + p1 = ok_response( + {"items": [_order("o1"), _order("o2")], "has_more": True, "limit": 2, "offset": 0} + ) + p2 = ok_response( + {"items": [_order("o3")], "has_more": False, "limit": 2, "offset": 2} + ) + transport, opener = make_transport([p1, p2]) + orders = list(TransactionOrdersResource(transport).iter("acct_1", page_size=2)) + assert [o.id for o in orders] == ["o1", "o2", "o3"] + assert "offset=2" in opener.calls[1]["url"] + + +def test_get_404_raises_order_not_found() -> None: + transport, _ = make_transport( + [ + http_error( + 404, {}, {"error": "no", "error_code": "TRANSACTION_ORDER_NOT_FOUND"} + ) + ] + ) + with pytest.raises(TransactionOrderNotFoundError): + TransactionOrdersResource(transport).get("acct_1", "missing") + + +def test_create_with_payment_method_id_wraps_body_and_forwards_idempotency_key() -> None: + transport, opener = make_transport([ok_response(_order("o1"), status=201)]) + TransactionOrdersResource(transport).create( + "acct_1", + amount="100.00", + currency="VES", + description="rent", + destination_payment_method_id="pm_dst", + idempotency_key="ik-001", + ) + call = opener.calls[0] + body = json.loads(call["body"].decode("utf-8")) + assert body == { + "transaction_order": { + "amount": "100.00", + "currency": "VES", + "description": "rent", + "destination_payment_method_id": "pm_dst", + "idempotency_key": "ik-001", + } + } + headers = dict(call["headers"]) + assert headers["Idempotency-key"] == "ik-001" + + +def test_create_with_beneficiary_dict_includes_it_in_body() -> None: + transport, opener = make_transport([ok_response(_order("o1"), status=201)]) + TransactionOrdersResource(transport).create( + "acct_1", + amount="50.00", + currency="VES", + description="payout", + beneficiary={"name": "Vendor", "bank_code": "0102", "account_number": "1234"}, + scheduled_for="2026-05-01T00:00:00Z", + metadata={"po": "PO-1"}, + ) + body = json.loads(opener.calls[0]["body"].decode("utf-8")) + inner = body["transaction_order"] + assert inner["beneficiary"]["name"] == "Vendor" + assert inner["scheduled_for"] == "2026-05-01T00:00:00Z" + assert inner["metadata"] == {"po": "PO-1"} + + +def test_create_validation_error_raises_typed_error() -> None: + transport, _ = make_transport( + [http_error(400, {}, {"error": "bad amount", "error_code": "VALIDATION_ERROR"})] + ) + with pytest.raises(ValidationError): + TransactionOrdersResource(transport).create( + "acct_1", amount="-1", currency="VES", description="bad" + ) + + +def test_submit_passes_token_when_provided() -> None: + transport, opener = make_transport( + [ok_response(_order("o1", "processing"), status=202)] + ) + TransactionOrdersResource(transport).submit("acct_1", "o1", token="otp-123") + body = json.loads(opener.calls[0]["body"].decode("utf-8")) + assert body == {"token": "otp-123"} + assert opener.calls[0]["url"].endswith("/v2/accounts/acct_1/transaction_orders/o1/submit") + + +def test_submit_invalid_order_state() -> None: + transport, _ = make_transport( + [http_error(409, {}, {"error": "wrong state", "error_code": "INVALID_ORDER_STATE"})] + ) + with pytest.raises(InvalidOrderStateError): + TransactionOrdersResource(transport).submit("acct_1", "o1") + + +def test_cancel_posts_empty_body_to_cancel_path() -> None: + transport, opener = make_transport([ok_response(_order("o1", "cancelled"))]) + res = TransactionOrdersResource(transport).cancel("acct_1", "o1") + assert res.status == "cancelled" + assert opener.calls[0]["url"].endswith("/v2/accounts/acct_1/transaction_orders/o1/cancel") + body = json.loads(opener.calls[0]["body"].decode("utf-8")) + assert body == {} + + +def test_415_when_content_type_not_json_maps_to_unprocessable() -> None: + """Ensures the 415 path is wired -- even though the SDK always sets the header, + a server-side 415 still propagates through the typed-error map.""" + from tesote_sdk.errors import UnprocessableContentError + + transport, _ = make_transport( + [ + http_error( + 415, + {}, + {"error": "need json", "error_code": "UNPROCESSABLE_CONTENT"}, + ) + ] + ) + with pytest.raises(UnprocessableContentError): + TransactionOrdersResource(transport).cancel("acct_1", "o1") diff --git a/packages/python/tests/test_v2_transactions.py b/packages/python/tests/test_v2_transactions.py new file mode 100644 index 0000000..0aa2092 --- /dev/null +++ b/packages/python/tests/test_v2_transactions.py @@ -0,0 +1,256 @@ +"""v2 transactions resource: list, sync, sync_legacy, bulk, search, export, get.""" + +from __future__ import annotations + +import pytest + +from tesote_sdk.errors import ( + HistorySyncForbiddenError, + InvalidCountError, + InvalidCursorError, + TransactionNotFoundError, + UnprocessableContentError, +) +from tesote_sdk.v2.transactions import TransactionsResource + +from ._helpers import make_transport +from .conftest import http_error, ok_response + + +def _v1_txn(tid: str) -> dict: + return { + "id": tid, + "status": "posted", + "data": {"amount_cents": 100, "currency": "VES", "description": tid}, + "tesote_imported_at": "2026-04-01T00:00:00Z", + "tesote_updated_at": "2026-04-01T00:00:00Z", + } + + +def _sync_txn(tid: str) -> dict: + return { + "transaction_id": tid, + "account_id": "acct_1", + "amount": 12.34, + "iso_currency_code": "VES", + "date": "2026-04-01", + "name": "Coffee", + "merchant_name": "Cafe", + "pending": False, + "category": ["food"], + } + + +def test_list_for_account_includes_v2_filters() -> None: + transport, opener = make_transport( + [ok_response({"total": 0, "transactions": [], "pagination": {"has_more": False}})] + ) + TransactionsResource(transport).list_for_account( + "acct_1", + amount_min=1.5, + amount_max=100.0, + status="posted", + category_id="cat_1", + counterparty_id="cp_1", + q="coffee", + ) + url = opener.calls[0]["url"] + assert "amount_min=1.5" in url + assert "amount_max=100" in url + assert "status=posted" in url + assert "category_id=cat_1" in url + assert "counterparty_id=cp_1" in url + assert "q=coffee" in url + + +def test_iter_for_account_follows_cursor() -> None: + p1 = ok_response( + { + "transactions": [_v1_txn("t1"), _v1_txn("t2")], + "pagination": {"has_more": True, "after_id": "t2", "before_id": "t1"}, + } + ) + p2 = ok_response( + { + "transactions": [_v1_txn("t3")], + "pagination": {"has_more": False, "after_id": "t3", "before_id": "t3"}, + } + ) + transport, opener = make_transport([p1, p2]) + txns = list(TransactionsResource(transport).iter_for_account("acct_1", per_page=2)) + assert [t.id for t in txns] == ["t1", "t2", "t3"] + assert "transactions_after_id=t2" in opener.calls[1]["url"] + + +def test_get_uses_v2_path() -> None: + transport, opener = make_transport([ok_response(_v1_txn("t_1"))]) + txn = TransactionsResource(transport).get("t_1") + assert txn.id == "t_1" + assert opener.calls[0]["url"].endswith("/v2/transactions/t_1") + + +def test_get_404() -> None: + transport, _ = make_transport( + [ + http_error( + 404, {}, {"error": "missing", "error_code": "TRANSACTION_NOT_FOUND"} + ) + ] + ) + with pytest.raises(TransactionNotFoundError): + TransactionsResource(transport).get("t_missing") + + +def test_sync_round_trip_with_options_and_idempotency() -> None: + transport, opener = make_transport( + [ + ok_response( + { + "added": [_sync_txn("a1")], + "modified": [_sync_txn("m1")], + "removed": [{"transaction_id": "r1", "account_id": "acct_1"}], + "next_cursor": "cur-next", + "has_more": False, + } + ) + ] + ) + delta = TransactionsResource(transport).sync( + "acct_1", + count=100, + cursor="now", + include_running_balance=True, + idempotency_key="sync-key", + ) + assert len(delta.added) == 1 + assert delta.added[0].transaction_id == "a1" + assert delta.removed[0].transaction_id == "r1" + assert delta.next_cursor == "cur-next" + assert delta.has_more is False + headers = dict(opener.calls[0]["headers"]) + assert headers["Idempotency-key"] == "sync-key" + # body shape: count + cursor + options.include_running_balance + import json as _json + + body = _json.loads(opener.calls[0]["body"].decode("utf-8")) + assert body == { + "count": 100, + "cursor": "now", + "options": {"include_running_balance": True}, + } + + +def test_sync_invalid_count_raises_typed_error() -> None: + transport, _ = make_transport( + [http_error(422, {}, {"error": "bad count", "error_code": "INVALID_COUNT"})] + ) + with pytest.raises(InvalidCountError): + TransactionsResource(transport).sync("acct_1", count=99999) + + +def test_sync_invalid_cursor_raises_typed_error() -> None: + transport, _ = make_transport( + [http_error(422, {}, {"error": "bad cur", "error_code": "INVALID_CURSOR"})] + ) + with pytest.raises(InvalidCursorError): + TransactionsResource(transport).sync("acct_1", cursor="garbage") + + +def test_sync_history_forbidden_raises_typed_error() -> None: + transport, _ = make_transport( + [ + http_error( + 403, {}, {"error": "too old", "error_code": "HISTORY_SYNC_FORBIDDEN"} + ) + ] + ) + with pytest.raises(HistorySyncForbiddenError): + TransactionsResource(transport).sync("acct_1", cursor="ancient") + + +def test_sync_legacy_uses_non_nested_path_and_passes_account_id_in_body() -> None: + transport, opener = make_transport( + [ + ok_response( + {"added": [], "modified": [], "removed": [], "next_cursor": None, "has_more": False} + ) + ] + ) + TransactionsResource(transport).sync_legacy(account_id="acct_1", count=10) + assert opener.calls[0]["url"].endswith("/v2/transactions/sync") + import json as _json + + body = _json.loads(opener.calls[0]["body"].decode("utf-8")) + assert body["account_id"] == "acct_1" + assert body["count"] == 10 + + +def test_bulk_sends_account_ids_and_returns_typed_results() -> None: + transport, opener = make_transport( + [ + ok_response( + { + "bulk_results": [ + { + "account_id": "acct_1", + "transactions": [_v1_txn("t1")], + "pagination": {"has_more": False, "after_id": "t1"}, + } + ] + } + ) + ] + ) + result = TransactionsResource(transport).bulk(["acct_1"], per_page=10) + assert len(result.bulk_results) == 1 + assert result.bulk_results[0].account_id == "acct_1" + assert result.bulk_results[0].transactions[0].id == "t1" + import json as _json + + body = _json.loads(opener.calls[0]["body"].decode("utf-8")) + assert body["account_ids"] == ["acct_1"] + assert body["per_page"] == 10 + + +def test_bulk_empty_account_ids_raises_unprocessable_content() -> None: + transport, _ = make_transport( + [ + http_error( + 422, {}, {"error": "no accounts", "error_code": "UNPROCESSABLE_CONTENT"} + ) + ] + ) + with pytest.raises(UnprocessableContentError): + TransactionsResource(transport).bulk([]) + + +def test_search_required_q_in_query_string() -> None: + transport, opener = make_transport( + [ok_response({"transactions": [_v1_txn("t1")], "total": 1})] + ) + result = TransactionsResource(transport).search( + "coffee", account_id="acct_1", limit=10 + ) + assert result.total == 1 + assert "q=coffee" in opener.calls[0]["url"] + assert "account_id=acct_1" in opener.calls[0]["url"] + + +def test_search_missing_q_returns_unprocessable_content() -> None: + transport, _ = make_transport( + [http_error(422, {}, {"error": "missing q", "error_code": "UNPROCESSABLE_CONTENT"})] + ) + with pytest.raises(UnprocessableContentError): + TransactionsResource(transport).search("") + + +def test_export_returns_raw_body_and_uses_format_param() -> None: + csv_body = b"Transaction ID,Date\nt1,2026-04-01\n" + transport, opener = make_transport( + [ok_response(csv_body, headers={"Content-Type": "text/csv"})] + ) + body = TransactionsResource(transport).export("acct_1", format="csv") + assert "Transaction ID" in body + url = opener.calls[0]["url"] + assert "format=csv" in url + assert "/v2/accounts/acct_1/transactions/export" in url From acda30c8d8a9401913cc8d40fa386b5ed9ea0917 Mon Sep 17 00:00:00 2001 From: sebi Date: Tue, 28 Apr 2026 19:44:07 -0500 Subject: [PATCH 06/10] ruby: implement full v1+v2 surface (0.2.0) 35 endpoints (6 v1 + 29 v2) wired against the Rails-controller-derived spec. Replaces lib/tesote_sdk/v2/stubs.rb with seven dedicated resource modules. Adds typed Struct models with keyword_init + tolerant from_hash/from_array, all spec error codes mapped to typed errors, cursor + offset enumerators, and request_unversioned + request_raw transport hooks for /status,/whoami at root and CSV/JSON export. rubocop clean (34 files, 0 offenses), 129 rspec examples pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/ruby/CHANGELOG.md | 44 ++ packages/ruby/lib/tesote_sdk.rb | 14 +- packages/ruby/lib/tesote_sdk/errors.rb | 69 +++ packages/ruby/lib/tesote_sdk/models.rb | 449 ++++++++++++++++++ packages/ruby/lib/tesote_sdk/pagination.rb | 100 ++++ packages/ruby/lib/tesote_sdk/transport.rb | 52 +- packages/ruby/lib/tesote_sdk/v1/accounts.rb | 38 +- packages/ruby/lib/tesote_sdk/v1/status.rb | 14 +- .../ruby/lib/tesote_sdk/v1/transactions.rb | 21 +- packages/ruby/lib/tesote_sdk/v2/accounts.rb | 28 +- packages/ruby/lib/tesote_sdk/v2/batches.rb | 72 +++ packages/ruby/lib/tesote_sdk/v2/client.rb | 7 +- .../ruby/lib/tesote_sdk/v2/payment_methods.rb | 56 +++ packages/ruby/lib/tesote_sdk/v2/status.rb | 21 + packages/ruby/lib/tesote_sdk/v2/stubs.rb | 125 ----- .../ruby/lib/tesote_sdk/v2/sync_sessions.rb | 42 ++ .../lib/tesote_sdk/v2/transaction_orders.rb | 73 +++ .../ruby/lib/tesote_sdk/v2/transactions.rb | 111 +++++ packages/ruby/lib/tesote_sdk/version.rb | 2 +- packages/ruby/spec/errors_spec.rb | 26 +- packages/ruby/spec/v1_accounts_spec.rb | 112 +++++ packages/ruby/spec/v1_status_spec.rb | 37 ++ packages/ruby/spec/v1_transactions_spec.rb | 50 ++ packages/ruby/spec/v2_accounts_spec.rb | 77 +++ packages/ruby/spec/v2_batches_spec.rb | 91 ++++ packages/ruby/spec/v2_payment_methods_spec.rb | 77 +++ packages/ruby/spec/v2_status_spec.rb | 21 + packages/ruby/spec/v2_sync_sessions_spec.rb | 51 ++ .../ruby/spec/v2_transaction_orders_spec.rb | 99 ++++ packages/ruby/spec/v2_transactions_spec.rb | 152 ++++++ 30 files changed, 1983 insertions(+), 148 deletions(-) create mode 100644 packages/ruby/lib/tesote_sdk/models.rb create mode 100644 packages/ruby/lib/tesote_sdk/pagination.rb create mode 100644 packages/ruby/lib/tesote_sdk/v2/batches.rb create mode 100644 packages/ruby/lib/tesote_sdk/v2/payment_methods.rb create mode 100644 packages/ruby/lib/tesote_sdk/v2/status.rb delete mode 100644 packages/ruby/lib/tesote_sdk/v2/stubs.rb create mode 100644 packages/ruby/lib/tesote_sdk/v2/sync_sessions.rb create mode 100644 packages/ruby/lib/tesote_sdk/v2/transaction_orders.rb create mode 100644 packages/ruby/lib/tesote_sdk/v2/transactions.rb create mode 100644 packages/ruby/spec/v1_accounts_spec.rb create mode 100644 packages/ruby/spec/v1_status_spec.rb create mode 100644 packages/ruby/spec/v1_transactions_spec.rb create mode 100644 packages/ruby/spec/v2_accounts_spec.rb create mode 100644 packages/ruby/spec/v2_batches_spec.rb create mode 100644 packages/ruby/spec/v2_payment_methods_spec.rb create mode 100644 packages/ruby/spec/v2_status_spec.rb create mode 100644 packages/ruby/spec/v2_sync_sessions_spec.rb create mode 100644 packages/ruby/spec/v2_transaction_orders_spec.rb create mode 100644 packages/ruby/spec/v2_transactions_spec.rb diff --git a/packages/ruby/CHANGELOG.md b/packages/ruby/CHANGELOG.md index edf31c4..720c8ac 100644 --- a/packages/ruby/CHANGELOG.md +++ b/packages/ruby/CHANGELOG.md @@ -4,6 +4,50 @@ All notable changes to `tesote-sdk` (Ruby) are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning is [SemVer](https://semver.org/) per the SDK's back-compat policy. +## 0.2.0 - 2026-04-28 + +### Added + +- Full v1 + v2 endpoint surface (35 endpoints total). v1 retains 6 + read-only endpoints (status, whoami, accounts, accounts.transactions, + transactions.get); v2 adds 29 endpoints across accounts, transactions, + sync_sessions, transaction_orders, batches, and payment_methods. +- Typed PORO/Struct models under `TesoteSdk::Models`: `Account`, + `Transaction`, `SyncTransaction`, `SyncResult`, `SyncSession`, + `TransactionOrder`, `PaymentMethod`, `BatchSummary`, `BatchCreateResult`, + `BulkResult`, `SearchResult`, `OffsetPage`, `Pagination`, `Whoami`, + `StatusResult`, plus nested types. All are forward-compatible with + unknown fields. +- Cursor and offset pagination helpers (`Pagination::CursorEnumerator`, + `Pagination::OffsetEnumerator`) — used by `Accounts#each_transaction_page`, + `Transactions#each_page_for_account`, and `SyncSessions#each_page`. +- New typed errors: `AccountNotFoundError`, `TransactionNotFoundError`, + `SyncSessionNotFoundError`, `PaymentMethodNotFoundError`, + `TransactionOrderNotFoundError`, `BatchNotFoundError`, + `CategoryNotFoundError`, `CounterpartyNotFoundError`, + `LegalEntityNotFoundError`, `WebhookNotFoundError`, + `BankConnectionNotFoundError`, `SyncInProgressError`, + `InvalidOrderStateError`, `MissingDateRangeError`, `InvalidCursorError`, + `InvalidCountError`, `InvalidLimitError`, `InvalidQueryError`, + `ValidationError`, `BatchValidationError`, `BankSubmissionError`, + `SyncRateLimitExceededError`, `InternalServerError`, + `BankUnderMaintenanceError`. All registered against their server + `error_code`. +- Transport: `request_unversioned` for the unversioned `/status` and + `/whoami` endpoints, and `request_raw` for file-download responses + (CSV/JSON export) returning a `RawResponse` with body, content-type, + and content-disposition. + +### Changed + +- `V1::Accounts#list`, `#get`, and `#list_transactions` now return typed + `Models::AccountList`, `Models::Account`, and `Models::TransactionList` + instead of raw hashes. Same for v2. +- The `v2/stubs.rb` placeholder file was removed; each resource now lives + in its own file (`v2/transactions.rb`, `v2/sync_sessions.rb`, + `v2/transaction_orders.rb`, `v2/batches.rb`, `v2/payment_methods.rb`, + `v2/status.rb`). + ## 0.1.0 - 2026-04-28 ### Added diff --git a/packages/ruby/lib/tesote_sdk.rb b/packages/ruby/lib/tesote_sdk.rb index 06cfede..2285fc8 100644 --- a/packages/ruby/lib/tesote_sdk.rb +++ b/packages/ruby/lib/tesote_sdk.rb @@ -1,6 +1,8 @@ require_relative 'tesote_sdk/version' require_relative 'tesote_sdk/errors' require_relative 'tesote_sdk/transport' +require_relative 'tesote_sdk/models' +require_relative 'tesote_sdk/pagination' module TesoteSdk module V1 @@ -13,11 +15,11 @@ module V1 module V2 autoload :Client, 'tesote_sdk/v2/client' autoload :Accounts, 'tesote_sdk/v2/accounts' - autoload :Transactions, 'tesote_sdk/v2/stubs' - autoload :SyncSessions, 'tesote_sdk/v2/stubs' - autoload :TransactionOrders, 'tesote_sdk/v2/stubs' - autoload :Batches, 'tesote_sdk/v2/stubs' - autoload :PaymentMethods, 'tesote_sdk/v2/stubs' - autoload :Status, 'tesote_sdk/v2/stubs' + autoload :Transactions, 'tesote_sdk/v2/transactions' + autoload :SyncSessions, 'tesote_sdk/v2/sync_sessions' + autoload :TransactionOrders, 'tesote_sdk/v2/transaction_orders' + autoload :Batches, 'tesote_sdk/v2/batches' + autoload :PaymentMethods, 'tesote_sdk/v2/payment_methods' + autoload :Status, 'tesote_sdk/v2/status' end end diff --git a/packages/ruby/lib/tesote_sdk/errors.rb b/packages/ruby/lib/tesote_sdk/errors.rb index 93181f2..86f804a 100644 --- a/packages/ruby/lib/tesote_sdk/errors.rb +++ b/packages/ruby/lib/tesote_sdk/errors.rb @@ -126,26 +126,95 @@ def self.parse_retry_after(response, envelope_value) end end + # 401 class UnauthorizedError < ApiError; end class ApiKeyRevokedError < ApiError; end + + # 403 class WorkspaceSuspendedError < ApiError; end class AccountDisabledError < ApiError; end class HistorySyncForbiddenError < ApiError; end + + # 404 + class NotFoundError < ApiError; end + class AccountNotFoundError < NotFoundError; end + class TransactionNotFoundError < NotFoundError; end + class SyncSessionNotFoundError < NotFoundError; end + class PaymentMethodNotFoundError < NotFoundError; end + class TransactionOrderNotFoundError < NotFoundError; end + class BatchNotFoundError < NotFoundError; end + class CategoryNotFoundError < NotFoundError; end + class CounterpartyNotFoundError < NotFoundError; end + class LegalEntityNotFoundError < NotFoundError; end + class WebhookNotFoundError < NotFoundError; end + class BankConnectionNotFoundError < NotFoundError; end + + # 409 class MutationDuringPaginationError < ApiError; end + class SyncInProgressError < ApiError; end + class InvalidOrderStateError < ApiError; end + + # 422 / 400 class UnprocessableContentError < ApiError; end class InvalidDateRangeError < ApiError; end + class MissingDateRangeError < ApiError; end + class InvalidCursorError < ApiError; end + class InvalidCountError < ApiError; end + class InvalidLimitError < ApiError; end + class InvalidQueryError < ApiError; end + class ValidationError < ApiError; end + class BatchValidationError < ApiError; end + class BankSubmissionError < ApiError; end + + # 429 class RateLimitExceededError < ApiError; end + class SyncRateLimitExceededError < ApiError; end + + # 500 + class InternalServerError < ApiError; end + + # 503 class ServiceUnavailableError < ApiError; end + class BankUnderMaintenanceError < ServiceUnavailableError; end ApiError.register('UNAUTHORIZED', UnauthorizedError) ApiError.register('API_KEY_REVOKED', ApiKeyRevokedError) ApiError.register('WORKSPACE_SUSPENDED', WorkspaceSuspendedError) ApiError.register('ACCOUNT_DISABLED', AccountDisabledError) ApiError.register('HISTORY_SYNC_FORBIDDEN', HistorySyncForbiddenError) + + ApiError.register('ACCOUNT_NOT_FOUND', AccountNotFoundError) + ApiError.register('TRANSACTION_NOT_FOUND', TransactionNotFoundError) + ApiError.register('SYNC_SESSION_NOT_FOUND', SyncSessionNotFoundError) + ApiError.register('PAYMENT_METHOD_NOT_FOUND', PaymentMethodNotFoundError) + ApiError.register('TRANSACTION_ORDER_NOT_FOUND', TransactionOrderNotFoundError) + ApiError.register('BATCH_NOT_FOUND', BatchNotFoundError) + ApiError.register('CATEGORY_NOT_FOUND', CategoryNotFoundError) + ApiError.register('COUNTERPARTY_NOT_FOUND', CounterpartyNotFoundError) + ApiError.register('LEGAL_ENTITY_NOT_FOUND', LegalEntityNotFoundError) + ApiError.register('WEBHOOK_NOT_FOUND', WebhookNotFoundError) + ApiError.register('BANK_CONNECTION_NOT_FOUND', BankConnectionNotFoundError) + ApiError.register('MUTATION_CONFLICT', MutationDuringPaginationError) + ApiError.register('SYNC_IN_PROGRESS', SyncInProgressError) + ApiError.register('INVALID_ORDER_STATE', InvalidOrderStateError) + ApiError.register('UNPROCESSABLE_CONTENT', UnprocessableContentError) ApiError.register('INVALID_DATE_RANGE', InvalidDateRangeError) + ApiError.register('MISSING_DATE_RANGE', MissingDateRangeError) + ApiError.register('INVALID_CURSOR', InvalidCursorError) + ApiError.register('INVALID_COUNT', InvalidCountError) + ApiError.register('INVALID_LIMIT', InvalidLimitError) + ApiError.register('INVALID_QUERY', InvalidQueryError) + ApiError.register('VALIDATION_ERROR', ValidationError) + ApiError.register('BATCH_VALIDATION_ERROR', BatchValidationError) + ApiError.register('BANK_SUBMISSION_ERROR', BankSubmissionError) + ApiError.register('RATE_LIMIT_EXCEEDED', RateLimitExceededError) + ApiError.register('SYNC_RATE_LIMIT_EXCEEDED', SyncRateLimitExceededError) + + ApiError.register('INTERNAL_ERROR', InternalServerError) + ApiError.register('BANK_UNDER_MAINTENANCE', BankUnderMaintenanceError) # Transport-level failures: no usable HTTP response. class TransportError < Error; end diff --git a/packages/ruby/lib/tesote_sdk/models.rb b/packages/ruby/lib/tesote_sdk/models.rb new file mode 100644 index 0000000..0aa5cb0 --- /dev/null +++ b/packages/ruby/lib/tesote_sdk/models.rb @@ -0,0 +1,449 @@ +module TesoteSdk + # Typed PORO models for API responses. Wire format is preserved on the right — + # attribute names match the JSON snake_case so callers can round-trip raw hashes + # via .from_hash without surprise. + # + # All model structs are keyword-init and tolerate unknown keys (forward-compat + # with future fields the SDK has not learned about yet). + module Models # rubocop:disable Metrics/ModuleLength + module FromHash + # why: API may grow new fields between SDK releases — drop unknown keys + # silently rather than crash. Clients can still get raw via response_body. + def from_hash(hash) + return nil if hash.nil? + return hash if hash.is_a?(self) + + hash = hash.transform_keys(&:to_s) if hash.is_a?(Hash) + known = members.map(&:to_s) + attrs = {} + known.each do |key| + attrs[key.to_sym] = build_field(key, hash[key]) + end + new(**attrs) + end + + def from_array(arr) + return [] if arr.nil? + + arr.map { |h| from_hash(h) } + end + + # Override in subclasses that wrap nested objects. + def build_field(_key, value) + value + end + end + + Bank = Struct.new(:name, keyword_init: true) do + extend FromHash + end + + LegalEntity = Struct.new(:id, :legal_name, keyword_init: true) do + extend FromHash + end + + AccountData = Struct.new( + :masked_account_number, + :currency, + :transactions_data_current_as_of, + :balance_data_current_as_of, + :custom_user_provided_identifier, + :balance_cents, + :available_balance_cents, + keyword_init: true + ) do + extend FromHash + end + + Account = Struct.new( + :id, + :name, + :data, + :bank, + :legal_entity, + :tesote_created_at, + :tesote_updated_at, + keyword_init: true + ) do + extend FromHash + + def self.build_field(key, value) + case key + when 'data' then AccountData.from_hash(value) + when 'bank' then Bank.from_hash(value) + when 'legal_entity' then LegalEntity.from_hash(value) + else value + end + end + end + + TransactionCategory = Struct.new( + :name, + :external_category_code, + :created_at, + :updated_at, + keyword_init: true + ) do + extend FromHash + end + + Counterparty = Struct.new(:id, :name, keyword_init: true) do + extend FromHash + end + + TransactionData = Struct.new( + :amount_cents, + :currency, + :description, + :transaction_date, + :created_at, + :created_at_date, + :note, + :external_service_id, + :running_balance_cents, + keyword_init: true + ) do + extend FromHash + end + + Transaction = Struct.new( + :id, + :status, + :data, + :tesote_imported_at, + :tesote_updated_at, + :transaction_categories, + :counterparty, + keyword_init: true + ) do + extend FromHash + + def self.build_field(key, value) + case key + when 'data' then TransactionData.from_hash(value) + when 'transaction_categories' then TransactionCategory.from_array(value) + when 'counterparty' then Counterparty.from_hash(value) + else value + end + end + end + + SyncTransaction = Struct.new( + :transaction_id, + :account_id, + :amount, + :iso_currency_code, + :unofficial_currency_code, + :date, + :datetime, + :name, + :merchant_name, + :pending, + :category, + :running_balance_cents, + keyword_init: true + ) do + extend FromHash + end + + SyncRemoval = Struct.new(:transaction_id, :account_id, keyword_init: true) do + extend FromHash + end + + SyncResult = Struct.new( + :added, + :modified, + :removed, + :next_cursor, + :has_more, + keyword_init: true + ) do + extend FromHash + + def self.build_field(key, value) + case key + when 'added', 'modified' then SyncTransaction.from_array(value) + when 'removed' then SyncRemoval.from_array(value) + else value + end + end + end + + SyncStartResult = Struct.new( + :message, + :sync_session_id, + :status, + :started_at, + keyword_init: true + ) do + extend FromHash + end + + SyncSessionError = Struct.new(:type, :message, keyword_init: true) do + extend FromHash + end + + SyncSessionPerformance = Struct.new( + :total_duration, + :complexity_score, + :sync_speed_score, + keyword_init: true + ) do + extend FromHash + end + + SyncSession = Struct.new( + :id, + :status, + :started_at, + :completed_at, + :transactions_synced, + :accounts_count, + :error, + :performance, + keyword_init: true + ) do + extend FromHash + + def self.build_field(key, value) + case key + when 'error' then SyncSessionError.from_hash(value) + when 'performance' then SyncSessionPerformance.from_hash(value) + else value + end + end + end + + SourceAccount = Struct.new(:id, :name, :payment_method_id, keyword_init: true) do + extend FromHash + end + + Destination = Struct.new( + :payment_method_id, + :counterparty_id, + :counterparty_name, + keyword_init: true + ) do + extend FromHash + end + + Fee = Struct.new(:amount, :currency, keyword_init: true) do + extend FromHash + end + + TesoteTransactionRef = Struct.new(:id, :status, keyword_init: true) do + extend FromHash + end + + LatestAttempt = Struct.new( + :id, + :status, + :attempt_number, + :external_reference, + :submitted_at, + :completed_at, + :error_code, + :error_message, + keyword_init: true + ) do + extend FromHash + end + + TransactionOrder = Struct.new( + :id, + :status, + :amount, + :currency, + :description, + :reference, + :external_reference, + :idempotency_key, + :batch_id, + :scheduled_for, + :approved_at, + :submitted_at, + :completed_at, + :failed_at, + :cancelled_at, + :source_account, + :destination, + :fee, + :execution_strategy, + :tesote_transaction, + :latest_attempt, + :created_at, + :updated_at, + keyword_init: true + ) do + extend FromHash + + def self.build_field(key, value) + case key + when 'source_account' then SourceAccount.from_hash(value) + when 'destination' then Destination.from_hash(value) + when 'fee' then Fee.from_hash(value) + when 'tesote_transaction' then TesoteTransactionRef.from_hash(value) + when 'latest_attempt' then LatestAttempt.from_hash(value) + else value + end + end + end + + TesoteAccountRef = Struct.new(:id, :name, keyword_init: true) do + extend FromHash + end + + PaymentMethod = Struct.new( + :id, + :method_type, + :currency, + :label, + :details, + :verified, + :verified_at, + :last_used_at, + :counterparty, + :tesote_account, + :created_at, + :updated_at, + keyword_init: true + ) do + extend FromHash + + def self.build_field(key, value) + case key + when 'counterparty' then Counterparty.from_hash(value) + when 'tesote_account' then TesoteAccountRef.from_hash(value) + else value + end + end + end + + BatchSummary = Struct.new( + :batch_id, + :total_orders, + :total_amount_cents, + :amount_currency, + :statuses, + :batch_status, + :created_at, + :orders, + keyword_init: true + ) do + extend FromHash + + def self.build_field(key, value) + case key + when 'orders' then TransactionOrder.from_array(value) + else value + end + end + end + + BatchCreateResult = Struct.new(:batch_id, :orders, :errors, keyword_init: true) do + extend FromHash + + def self.build_field(key, value) + case key + when 'orders' then TransactionOrder.from_array(value) + else value + end + end + end + + Pagination = Struct.new( + :current_page, + :per_page, + :total_pages, + :total_count, + :has_more, + :after_id, + :before_id, + keyword_init: true + ) do + extend FromHash + end + + AccountList = Struct.new(:accounts, :total, :pagination, keyword_init: true) do + extend FromHash + + def self.build_field(key, value) + case key + when 'accounts' then Account.from_array(value) + when 'pagination' then Pagination.from_hash(value) + else value + end + end + end + + TransactionList = Struct.new(:transactions, :total, :pagination, keyword_init: true) do + extend FromHash + + def self.build_field(key, value) + case key + when 'transactions' then Transaction.from_array(value) + when 'pagination' then Pagination.from_hash(value) + else value + end + end + end + + OffsetPage = Struct.new(:items, :limit, :offset, :has_more, keyword_init: true) do + extend FromHash + end + + SearchResult = Struct.new(:transactions, :total, keyword_init: true) do + extend FromHash + + def self.build_field(key, value) + case key + when 'transactions' then Transaction.from_array(value) + else value + end + end + end + + BulkAccountResult = Struct.new(:account_id, :transactions, :pagination, keyword_init: true) do + extend FromHash + + def self.build_field(key, value) + case key + when 'transactions' then Transaction.from_array(value) + when 'pagination' then Pagination.from_hash(value) + else value + end + end + end + + BulkResult = Struct.new(:bulk_results, keyword_init: true) do + extend FromHash + + def self.build_field(key, value) + case key + when 'bulk_results' then BulkAccountResult.from_array(value) + else value + end + end + end + + Whoami = Struct.new(:client, keyword_init: true) do + extend FromHash + end + + StatusResult = Struct.new(:status, :authenticated, keyword_init: true) do + extend FromHash + end + + BatchApproveResult = Struct.new(:approved, :failed, keyword_init: true) do + extend FromHash + end + + BatchSubmitResult = Struct.new(:enqueued, :failed, keyword_init: true) do + extend FromHash + end + + BatchCancelResult = Struct.new(:cancelled, :skipped, :errors, keyword_init: true) do + extend FromHash + end + end +end diff --git a/packages/ruby/lib/tesote_sdk/pagination.rb b/packages/ruby/lib/tesote_sdk/pagination.rb new file mode 100644 index 0000000..e8c5640 --- /dev/null +++ b/packages/ruby/lib/tesote_sdk/pagination.rb @@ -0,0 +1,100 @@ +module TesoteSdk + # Generic pagination helpers. Resource clients return native model objects; + # these enumerators wrap repeated calls so callers can iterate the full set + # without re-implementing cursor or offset arithmetic. + module Pagination + # Cursor pagination (transactions index): walks `pagination.after_id` until + # has_more is false. Yields the raw page hash so callers can read the + # response envelope as needed. + class CursorEnumerator + include Enumerable + + def initialize(start_query: {}, &fetch_page) + raise ArgumentError, 'block (fetch_page) is required' unless block_given? + + @start_query = start_query.dup + @fetch_page = fetch_page + end + + def each + return enum_for(:each) unless block_given? + + query = @start_query.dup + loop do + page = @fetch_page.call(query) + yield page + + pagination = pagination_hash(page) + break unless pagination['has_more'] + + after_id = pagination['after_id'] + break if after_id.nil? || after_id.to_s.empty? + + query = query.merge(transactions_after_id: after_id) + end + end + + private + + def pagination_hash(page) + return {} if page.nil? + + if page.is_a?(Hash) + (page['pagination'] || page[:pagination] || {}).transform_keys(&:to_s) + elsif page.respond_to?(:pagination) + to_pagination_hash(page.pagination) + else + {} + end + end + + def to_pagination_hash(value) + return {} if value.nil? + return value.transform_keys(&:to_s) if value.is_a?(Hash) + return value.to_h.transform_keys(&:to_s) if value.respond_to?(:to_h) + + {} + end + end + + # Offset pagination: walks until has_more is false, advancing `offset` by + # `limit`. Yields each page hash. + class OffsetEnumerator + include Enumerable + + def initialize(start_query: {}, limit: 50, &fetch_page) + raise ArgumentError, 'block (fetch_page) is required' unless block_given? + + @start_query = start_query.dup + @limit = limit + @fetch_page = fetch_page + end + + def each + return enum_for(:each) unless block_given? + + offset = (@start_query[:offset] || @start_query['offset'] || 0).to_i + loop do + query = @start_query.merge(limit: @limit, offset: offset) + page = @fetch_page.call(query) + yield page + + break unless page_has_more?(page) + + offset += @limit + end + end + + private + + def page_has_more?(page) + return false if page.nil? + return !!page['has_more'] if page.is_a?(Hash) && page.key?('has_more') + return !!page[:has_more] if page.is_a?(Hash) && page.key?(:has_more) + return !!page.has_more if page.respond_to?(:has_more) + + false + end + end + end +end diff --git a/packages/ruby/lib/tesote_sdk/transport.rb b/packages/ruby/lib/tesote_sdk/transport.rb index fc8551c..0edb3d3 100644 --- a/packages/ruby/lib/tesote_sdk/transport.rb +++ b/packages/ruby/lib/tesote_sdk/transport.rb @@ -110,6 +110,47 @@ def initialize(api_key:, def request(method, path, query: nil, body: nil, opts: {}) method_upper = method.to_s.upcase uri = build_uri(path, query) + execute_request(method_upper, uri, body, opts) + end + + # why: GET /status and GET /whoami live at the API root, not under + # /v1 or /v2 — bypass the version_segment but reuse all cross-cutting. + def request_unversioned(method, path, query: nil, body: nil, opts: {}) + method_upper = method.to_s.upcase + uri = build_unversioned_uri(path, query) + execute_request(method_upper, uri, body, opts) + end + + # Returns a RawResponse with body string + headers — used for file-download + # endpoints (CSV/JSON export) where the SDK should not parse the body. + RawResponse = Struct.new(:status, :body, :content_type, :content_disposition, :request_id, keyword_init: true) + + def request_raw(method, path, query: nil, body: nil, opts: {}) + method_upper = method.to_s.upcase + uri = build_uri(path, query) + request_summary = build_request_summary(method_upper, uri, body) + response, body_str, attempts = perform_with_retries(method_upper, uri, body, opts, request_summary) + + record_rate_limit(response) + @last_request_id = response['x-request-id'] || response['X-Request-Id'] + status = response.code.to_i + + if status >= 200 && status < 300 + return RawResponse.new( + status: status, + body: body_str, + content_type: response['content-type'] || response['Content-Type'], + content_disposition: response['content-disposition'] || response['Content-Disposition'], + request_id: @last_request_id + ) + end + + raise ApiError.from_response(response, body_str, request_summary, attempts: attempts) + end + + private + + def execute_request(method_upper, uri, body, opts) cache_key = cache_key_for(method_upper, uri, opts) cached = cache_lookup(method_upper, cache_key, opts) return cached unless cached.nil? @@ -131,8 +172,6 @@ def request(method, path, query: nil, body: nil, opts: {}) raise ApiError.from_response(response, body_str, request_summary, attempts: attempts) end - private - def default_user_agent "tesote-sdk-rb/#{TesoteSdk::VERSION} (ruby/#{RUBY_VERSION})" end @@ -146,6 +185,15 @@ def build_uri(path, query) uri end + def build_unversioned_uri(path, query) + joined = "#{base_url}/#{path.to_s.sub(%r{\A/+}, '')}" + uri = URI.parse(joined) + if query && !query.empty? + uri.query = URI.encode_www_form(stringify_query(query)) + end + uri + end + def stringify_query(query) query.each_with_object([]) do |(key, value), acc| next if value.nil? diff --git a/packages/ruby/lib/tesote_sdk/v1/accounts.rb b/packages/ruby/lib/tesote_sdk/v1/accounts.rb index 4f6fbd9..881008f 100644 --- a/packages/ruby/lib/tesote_sdk/v1/accounts.rb +++ b/packages/ruby/lib/tesote_sdk/v1/accounts.rb @@ -1,18 +1,52 @@ module TesoteSdk module V1 class Accounts + INDEX_CACHE_TTL = 60 # 1 minute ETag (spec) + SHOW_CACHE_TTL = 300 # 5 minutes ETag (spec) + def initialize(transport) @transport = transport end + # GET /v1/accounts def list(query = {}, opts: {}) - @transport.request('GET', 'accounts', query: query, opts: opts) + body = @transport.request('GET', 'accounts', query: query, opts: with_cache(opts, INDEX_CACHE_TTL)) + Models::AccountList.from_hash(body) end + # GET /v1/accounts/{id} def get(id, opts: {}) raise ArgumentError, 'id is required' if id.nil? || id.to_s.empty? - @transport.request('GET', "accounts/#{id}", opts: opts) + body = @transport.request('GET', "accounts/#{id}", opts: with_cache(opts, SHOW_CACHE_TTL)) + Models::Account.from_hash(body) + end + + # GET /v1/accounts/{id}/transactions + def list_transactions(account_id, query = {}, opts: {}) + raise ArgumentError, 'account_id is required' if account_id.nil? || account_id.to_s.empty? + + body = @transport.request('GET', "accounts/#{account_id}/transactions", query: query, opts: opts) + Models::TransactionList.from_hash(body) + end + + # Enumerate all transactions across pages (cursor-based). + # Yields TransactionList page objects. + def each_transaction_page(account_id, query = {}, opts: {}, &block) + return enum_for(:each_transaction_page, account_id, query, opts: opts) unless block + + enum = Pagination::CursorEnumerator.new(start_query: query) do |q| + list_transactions(account_id, q, opts: opts) + end + enum.each(&block) + end + + private + + def with_cache(opts, ttl) + return opts if opts.key?(:cache) + + opts.merge(cache: { ttl: ttl }) end end end diff --git a/packages/ruby/lib/tesote_sdk/v1/status.rb b/packages/ruby/lib/tesote_sdk/v1/status.rb index f722aee..fc8d8c8 100644 --- a/packages/ruby/lib/tesote_sdk/v1/status.rb +++ b/packages/ruby/lib/tesote_sdk/v1/status.rb @@ -1,16 +1,26 @@ module TesoteSdk module V1 + # GET /status, GET /whoami — both live under the API root, NOT under /v1. + # We bypass the transport's version_segment by using opts[:extra_headers] is + # not enough; we need an absolute path. The transport joins + # base_url + version_segment + path, so we send a leading double-slash + # path? No — instead we use an unversioned subclient via raw_request. class Status def initialize(transport) @transport = transport end + # GET /status (no auth required, but transport always sends it — server + # ignores when not required). def status(opts: {}) - raise NotImplementedError, 'V1::Status#status not wired in 0.1.0' + body = @transport.request_unversioned('GET', 'status', opts: opts) + Models::StatusResult.from_hash(body) end + # GET /whoami def whoami(opts: {}) - raise NotImplementedError, 'V1::Status#whoami not wired in 0.1.0' + body = @transport.request_unversioned('GET', 'whoami', opts: opts) + Models::Whoami.from_hash(body) end end end diff --git a/packages/ruby/lib/tesote_sdk/v1/transactions.rb b/packages/ruby/lib/tesote_sdk/v1/transactions.rb index e279735..1bdd9b4 100644 --- a/packages/ruby/lib/tesote_sdk/v1/transactions.rb +++ b/packages/ruby/lib/tesote_sdk/v1/transactions.rb @@ -1,16 +1,29 @@ module TesoteSdk module V1 class Transactions + SHOW_CACHE_TTL = 300 + def initialize(transport) @transport = transport end - def list_for_account(_account_id, _query = {}, opts: {}) - raise NotImplementedError, 'V1::Transactions#list_for_account not wired in 0.1.0' + # GET /v1/accounts/{id}/transactions — convenience pass-through to + # V1::Accounts#list_transactions for callers that prefer to start from + # the transactions client. + def list_for_account(account_id, query = {}, opts: {}) + raise ArgumentError, 'account_id is required' if account_id.nil? || account_id.to_s.empty? + + body = @transport.request('GET', "accounts/#{account_id}/transactions", query: query, opts: opts) + Models::TransactionList.from_hash(body) end - def get(_id, opts: {}) - raise NotImplementedError, 'V1::Transactions#get not wired in 0.1.0' + # GET /v1/transactions/{id} + def get(id, opts: {}) + raise ArgumentError, 'id is required' if id.nil? || id.to_s.empty? + + merged = opts.key?(:cache) ? opts : opts.merge(cache: { ttl: SHOW_CACHE_TTL }) + body = @transport.request('GET', "transactions/#{id}", opts: merged) + Models::Transaction.from_hash(body) end end end diff --git a/packages/ruby/lib/tesote_sdk/v2/accounts.rb b/packages/ruby/lib/tesote_sdk/v2/accounts.rb index ea5aad8..593efca 100644 --- a/packages/ruby/lib/tesote_sdk/v2/accounts.rb +++ b/packages/ruby/lib/tesote_sdk/v2/accounts.rb @@ -1,22 +1,42 @@ module TesoteSdk module V2 class Accounts + INDEX_CACHE_TTL = 60 + SHOW_CACHE_TTL = 300 + def initialize(transport) @transport = transport end + # GET /v2/accounts def list(query = {}, opts: {}) - @transport.request('GET', 'accounts', query: query, opts: opts) + body = @transport.request('GET', 'accounts', query: query, opts: with_cache(opts, INDEX_CACHE_TTL)) + Models::AccountList.from_hash(body) end + # GET /v2/accounts/{id} def get(id, opts: {}) raise ArgumentError, 'id is required' if id.nil? || id.to_s.empty? - @transport.request('GET', "accounts/#{id}", opts: opts) + body = @transport.request('GET', "accounts/#{id}", opts: with_cache(opts, SHOW_CACHE_TTL)) + Models::Account.from_hash(body) end - def sync(_id, opts: {}) - raise NotImplementedError, 'V2::Accounts#sync not wired in 0.1.0' + # POST /v2/accounts/{id}/sync + # Triggers an async sync; returns SyncStartResult (status: pending). + def sync(id, opts: {}) + raise ArgumentError, 'id is required' if id.nil? || id.to_s.empty? + + body = @transport.request('POST', "accounts/#{id}/sync", body: {}, opts: opts) + Models::SyncStartResult.from_hash(body) + end + + private + + def with_cache(opts, ttl) + return opts if opts.key?(:cache) + + opts.merge(cache: { ttl: ttl }) end end end diff --git a/packages/ruby/lib/tesote_sdk/v2/batches.rb b/packages/ruby/lib/tesote_sdk/v2/batches.rb new file mode 100644 index 0000000..9c59f8d --- /dev/null +++ b/packages/ruby/lib/tesote_sdk/v2/batches.rb @@ -0,0 +1,72 @@ +module TesoteSdk + module V2 + class Batches + def initialize(transport) + @transport = transport + end + + # POST /v2/accounts/{id}/batches + # `orders` is an array of order hashes (per spec). + def create(account_id, orders:, opts: {}) + raise ArgumentError, 'account_id is required' if account_id.nil? || account_id.to_s.empty? + raise ArgumentError, 'orders is required' if orders.nil? || orders.empty? + + payload = { orders: orders } + body = @transport.request('POST', "accounts/#{account_id}/batches", body: payload, opts: opts) + Models::BatchCreateResult.from_hash(body) + end + + # GET /v2/accounts/{id}/batches/{batch_id} + def get(account_id, batch_id, opts: {}) + raise ArgumentError, 'account_id is required' if account_id.nil? || account_id.to_s.empty? + raise ArgumentError, 'batch_id is required' if batch_id.nil? || batch_id.to_s.empty? + + body = @transport.request('GET', "accounts/#{account_id}/batches/#{batch_id}", opts: opts) + Models::BatchSummary.from_hash(body) + end + + # POST /v2/accounts/{id}/batches/{batch_id}/approve + def approve(account_id, batch_id, opts: {}) + raise ArgumentError, 'account_id is required' if account_id.nil? || account_id.to_s.empty? + raise ArgumentError, 'batch_id is required' if batch_id.nil? || batch_id.to_s.empty? + + body = @transport.request( + 'POST', + "accounts/#{account_id}/batches/#{batch_id}/approve", + body: {}, + opts: opts + ) + Models::BatchApproveResult.from_hash(body) + end + + # POST /v2/accounts/{id}/batches/{batch_id}/submit + def submit(account_id, batch_id, token: nil, opts: {}) + raise ArgumentError, 'account_id is required' if account_id.nil? || account_id.to_s.empty? + raise ArgumentError, 'batch_id is required' if batch_id.nil? || batch_id.to_s.empty? + + payload = token.nil? ? {} : { token: token } + body = @transport.request( + 'POST', + "accounts/#{account_id}/batches/#{batch_id}/submit", + body: payload, + opts: opts + ) + Models::BatchSubmitResult.from_hash(body) + end + + # POST /v2/accounts/{id}/batches/{batch_id}/cancel + def cancel(account_id, batch_id, opts: {}) + raise ArgumentError, 'account_id is required' if account_id.nil? || account_id.to_s.empty? + raise ArgumentError, 'batch_id is required' if batch_id.nil? || batch_id.to_s.empty? + + body = @transport.request( + 'POST', + "accounts/#{account_id}/batches/#{batch_id}/cancel", + body: {}, + opts: opts + ) + Models::BatchCancelResult.from_hash(body) + end + end + end +end diff --git a/packages/ruby/lib/tesote_sdk/v2/client.rb b/packages/ruby/lib/tesote_sdk/v2/client.rb index c851497..358a2e6 100644 --- a/packages/ruby/lib/tesote_sdk/v2/client.rb +++ b/packages/ruby/lib/tesote_sdk/v2/client.rb @@ -1,6 +1,11 @@ require_relative '../transport' require_relative 'accounts' -require_relative 'stubs' +require_relative 'transactions' +require_relative 'sync_sessions' +require_relative 'transaction_orders' +require_relative 'batches' +require_relative 'payment_methods' +require_relative 'status' module TesoteSdk module V2 diff --git a/packages/ruby/lib/tesote_sdk/v2/payment_methods.rb b/packages/ruby/lib/tesote_sdk/v2/payment_methods.rb new file mode 100644 index 0000000..b8e7ed0 --- /dev/null +++ b/packages/ruby/lib/tesote_sdk/v2/payment_methods.rb @@ -0,0 +1,56 @@ +module TesoteSdk + module V2 + class PaymentMethods + def initialize(transport) + @transport = transport + end + + # GET /v2/payment_methods + def list(query = {}, opts: {}) + body = @transport.request('GET', 'payment_methods', query: query, opts: opts) + items_raw = (body && body['items']) || [] + Models::OffsetPage.new( + items: Models::PaymentMethod.from_array(items_raw), + limit: body && body['limit'], + offset: body && body['offset'], + has_more: body && body['has_more'] + ) + end + + # GET /v2/payment_methods/{id} + def get(id, opts: {}) + raise ArgumentError, 'id is required' if id.nil? || id.to_s.empty? + + body = @transport.request('GET', "payment_methods/#{id}", opts: opts) + Models::PaymentMethod.from_hash(body) + end + + # POST /v2/payment_methods + def create(payment_method:, opts: {}) + raise ArgumentError, 'payment_method is required' if payment_method.nil? + + payload = { payment_method: payment_method } + body = @transport.request('POST', 'payment_methods', body: payload, opts: opts) + Models::PaymentMethod.from_hash(body) + end + + # PATCH /v2/payment_methods/{id} + def update(id, payment_method:, opts: {}) + raise ArgumentError, 'id is required' if id.nil? || id.to_s.empty? + raise ArgumentError, 'payment_method is required' if payment_method.nil? + + payload = { payment_method: payment_method } + body = @transport.request('PATCH', "payment_methods/#{id}", body: payload, opts: opts) + Models::PaymentMethod.from_hash(body) + end + + # DELETE /v2/payment_methods/{id} → 204 No Content + def delete(id, opts: {}) + raise ArgumentError, 'id is required' if id.nil? || id.to_s.empty? + + @transport.request('DELETE', "payment_methods/#{id}", opts: opts) + nil + end + end + end +end diff --git a/packages/ruby/lib/tesote_sdk/v2/status.rb b/packages/ruby/lib/tesote_sdk/v2/status.rb new file mode 100644 index 0000000..fbb4ea7 --- /dev/null +++ b/packages/ruby/lib/tesote_sdk/v2/status.rb @@ -0,0 +1,21 @@ +module TesoteSdk + module V2 + class Status + def initialize(transport) + @transport = transport + end + + # GET /v2/status — note this lives at /api/v2/status (not /api/status). + def status(opts: {}) + body = @transport.request('GET', 'status', opts: opts) + Models::StatusResult.from_hash(body) + end + + # GET /v2/whoami + def whoami(opts: {}) + body = @transport.request('GET', 'whoami', opts: opts) + Models::Whoami.from_hash(body) + end + end + end +end diff --git a/packages/ruby/lib/tesote_sdk/v2/stubs.rb b/packages/ruby/lib/tesote_sdk/v2/stubs.rb deleted file mode 100644 index 936f57a..0000000 --- a/packages/ruby/lib/tesote_sdk/v2/stubs.rb +++ /dev/null @@ -1,125 +0,0 @@ -module TesoteSdk - module V2 - # Resource clients whose endpoints are documented but not wired in 0.1.0. - # Each method raises NotImplementedError so the public surface is discoverable - # while back-compat constraints stay honest. - - class StubBase - def initialize(transport) - @transport = transport - end - end - - class Transactions < StubBase - def list_for_account(_account_id, _query = {}, opts: {}) - raise NotImplementedError, 'V2::Transactions#list_for_account not wired in 0.1.0' - end - - def get(_id, opts: {}) - raise NotImplementedError, 'V2::Transactions#get not wired in 0.1.0' - end - - def export(_account_id, _params = {}, opts: {}) - raise NotImplementedError, 'V2::Transactions#export not wired in 0.1.0' - end - - def sync(_account_id, opts: {}) - raise NotImplementedError, 'V2::Transactions#sync not wired in 0.1.0' - end - - def bulk(_payload, opts: {}) - raise NotImplementedError, 'V2::Transactions#bulk not wired in 0.1.0' - end - - def search(_query = {}, opts: {}) - raise NotImplementedError, 'V2::Transactions#search not wired in 0.1.0' - end - end - - class SyncSessions < StubBase - def list(_account_id, _query = {}, opts: {}) - raise NotImplementedError, 'V2::SyncSessions#list not wired in 0.1.0' - end - - def get(_account_id, _id, opts: {}) - raise NotImplementedError, 'V2::SyncSessions#get not wired in 0.1.0' - end - end - - class TransactionOrders < StubBase - def list(_account_id, _query = {}, opts: {}) - raise NotImplementedError, 'V2::TransactionOrders#list not wired in 0.1.0' - end - - def get(_account_id, _id, opts: {}) - raise NotImplementedError, 'V2::TransactionOrders#get not wired in 0.1.0' - end - - def create(_account_id, _payload, opts: {}) - raise NotImplementedError, 'V2::TransactionOrders#create not wired in 0.1.0' - end - - def submit(_account_id, _id, opts: {}) - raise NotImplementedError, 'V2::TransactionOrders#submit not wired in 0.1.0' - end - - def cancel(_account_id, _id, opts: {}) - raise NotImplementedError, 'V2::TransactionOrders#cancel not wired in 0.1.0' - end - end - - class Batches < StubBase - def create(_payload, opts: {}) - raise NotImplementedError, 'V2::Batches#create not wired in 0.1.0' - end - - def get(_id, opts: {}) - raise NotImplementedError, 'V2::Batches#get not wired in 0.1.0' - end - - def approve(_id, opts: {}) - raise NotImplementedError, 'V2::Batches#approve not wired in 0.1.0' - end - - def submit(_id, opts: {}) - raise NotImplementedError, 'V2::Batches#submit not wired in 0.1.0' - end - - def cancel(_id, opts: {}) - raise NotImplementedError, 'V2::Batches#cancel not wired in 0.1.0' - end - end - - class PaymentMethods < StubBase - def list(_query = {}, opts: {}) - raise NotImplementedError, 'V2::PaymentMethods#list not wired in 0.1.0' - end - - def get(_id, opts: {}) - raise NotImplementedError, 'V2::PaymentMethods#get not wired in 0.1.0' - end - - def create(_payload, opts: {}) - raise NotImplementedError, 'V2::PaymentMethods#create not wired in 0.1.0' - end - - def update(_id, _payload, opts: {}) - raise NotImplementedError, 'V2::PaymentMethods#update not wired in 0.1.0' - end - - def delete(_id, opts: {}) - raise NotImplementedError, 'V2::PaymentMethods#delete not wired in 0.1.0' - end - end - - class Status < StubBase - def status(opts: {}) - raise NotImplementedError, 'V2::Status#status not wired in 0.1.0' - end - - def whoami(opts: {}) - raise NotImplementedError, 'V2::Status#whoami not wired in 0.1.0' - end - end - end -end diff --git a/packages/ruby/lib/tesote_sdk/v2/sync_sessions.rb b/packages/ruby/lib/tesote_sdk/v2/sync_sessions.rb new file mode 100644 index 0000000..97ccad3 --- /dev/null +++ b/packages/ruby/lib/tesote_sdk/v2/sync_sessions.rb @@ -0,0 +1,42 @@ +module TesoteSdk + module V2 + class SyncSessions + def initialize(transport) + @transport = transport + end + + # GET /v2/accounts/{id}/sync_sessions + def list(account_id, query = {}, opts: {}) + raise ArgumentError, 'account_id is required' if account_id.nil? || account_id.to_s.empty? + + body = @transport.request('GET', "accounts/#{account_id}/sync_sessions", query: query, opts: opts) + items_raw = (body && body['sync_sessions']) || [] + Models::OffsetPage.new( + items: Models::SyncSession.from_array(items_raw), + limit: body && body['limit'], + offset: body && body['offset'], + has_more: body && body['has_more'] + ) + end + + # Walks pages via offset pagination; yields OffsetPage per call. + def each_page(account_id, query = {}, opts: {}, page_size: 50, &block) + return enum_for(:each_page, account_id, query, opts: opts, page_size: page_size) unless block + + enum = Pagination::OffsetEnumerator.new(start_query: query, limit: page_size) do |q| + list(account_id, q, opts: opts) + end + enum.each(&block) + end + + # GET /v2/accounts/{id}/sync_sessions/{session_id} + def get(account_id, session_id, opts: {}) + raise ArgumentError, 'account_id is required' if account_id.nil? || account_id.to_s.empty? + raise ArgumentError, 'session_id is required' if session_id.nil? || session_id.to_s.empty? + + body = @transport.request('GET', "accounts/#{account_id}/sync_sessions/#{session_id}", opts: opts) + Models::SyncSession.from_hash(body) + end + end + end +end diff --git a/packages/ruby/lib/tesote_sdk/v2/transaction_orders.rb b/packages/ruby/lib/tesote_sdk/v2/transaction_orders.rb new file mode 100644 index 0000000..c4f8013 --- /dev/null +++ b/packages/ruby/lib/tesote_sdk/v2/transaction_orders.rb @@ -0,0 +1,73 @@ +module TesoteSdk + module V2 + class TransactionOrders + def initialize(transport) + @transport = transport + end + + # GET /v2/accounts/{id}/transaction_orders + # Returns OffsetPage with TransactionOrder items. + def list(account_id, query = {}, opts: {}) + raise ArgumentError, 'account_id is required' if account_id.nil? || account_id.to_s.empty? + + body = @transport.request('GET', "accounts/#{account_id}/transaction_orders", query: query, opts: opts) + items_raw = (body && body['items']) || [] + Models::OffsetPage.new( + items: Models::TransactionOrder.from_array(items_raw), + limit: body && body['limit'], + offset: body && body['offset'], + has_more: body && body['has_more'] + ) + end + + # GET /v2/accounts/{id}/transaction_orders/{order_id} + def get(account_id, order_id, opts: {}) + raise ArgumentError, 'account_id is required' if account_id.nil? || account_id.to_s.empty? + raise ArgumentError, 'order_id is required' if order_id.nil? || order_id.to_s.empty? + + body = @transport.request('GET', "accounts/#{account_id}/transaction_orders/#{order_id}", opts: opts) + Models::TransactionOrder.from_hash(body) + end + + # POST /v2/accounts/{id}/transaction_orders + # `order` is a hash matching the spec's `transaction_order` body field. + def create(account_id, order:, opts: {}) + raise ArgumentError, 'account_id is required' if account_id.nil? || account_id.to_s.empty? + raise ArgumentError, 'order is required' if order.nil? + + payload = { transaction_order: order } + body = @transport.request('POST', "accounts/#{account_id}/transaction_orders", body: payload, opts: opts) + Models::TransactionOrder.from_hash(body) + end + + # POST /v2/accounts/{id}/transaction_orders/{order_id}/submit + def submit(account_id, order_id, token: nil, opts: {}) + raise ArgumentError, 'account_id is required' if account_id.nil? || account_id.to_s.empty? + raise ArgumentError, 'order_id is required' if order_id.nil? || order_id.to_s.empty? + + payload = token.nil? ? {} : { token: token } + body = @transport.request( + 'POST', + "accounts/#{account_id}/transaction_orders/#{order_id}/submit", + body: payload, + opts: opts + ) + Models::TransactionOrder.from_hash(body) + end + + # POST /v2/accounts/{id}/transaction_orders/{order_id}/cancel + def cancel(account_id, order_id, opts: {}) + raise ArgumentError, 'account_id is required' if account_id.nil? || account_id.to_s.empty? + raise ArgumentError, 'order_id is required' if order_id.nil? || order_id.to_s.empty? + + body = @transport.request( + 'POST', + "accounts/#{account_id}/transaction_orders/#{order_id}/cancel", + body: {}, + opts: opts + ) + Models::TransactionOrder.from_hash(body) + end + end + end +end diff --git a/packages/ruby/lib/tesote_sdk/v2/transactions.rb b/packages/ruby/lib/tesote_sdk/v2/transactions.rb new file mode 100644 index 0000000..ef21814 --- /dev/null +++ b/packages/ruby/lib/tesote_sdk/v2/transactions.rb @@ -0,0 +1,111 @@ +module TesoteSdk + module V2 + # Wraps: + # - GET /v2/accounts/{id}/transactions + # - GET /v2/accounts/{id}/transactions/export + # - POST /v2/accounts/{id}/transactions/sync + # - POST /v2/transactions/sync (legacy) + # - GET /v2/transactions/{id} + # - POST /v2/transactions/bulk + # - GET /v2/transactions/search + class Transactions + INDEX_CACHE_TTL = 60 + SHOW_CACHE_TTL = 300 + + def initialize(transport) + @transport = transport + end + + # GET /v2/accounts/{id}/transactions + def list_for_account(account_id, query = {}, opts: {}) + raise ArgumentError, 'account_id is required' if account_id.nil? || account_id.to_s.empty? + + merged = opts.key?(:cache) ? opts : opts.merge(cache: { ttl: INDEX_CACHE_TTL }) + body = @transport.request('GET', "accounts/#{account_id}/transactions", query: query, opts: merged) + Models::TransactionList.from_hash(body) + end + + # Cursor pagination over list_for_account; yields TransactionList pages. + def each_page_for_account(account_id, query = {}, opts: {}, &block) + return enum_for(:each_page_for_account, account_id, query, opts: opts) unless block + + enum = Pagination::CursorEnumerator.new(start_query: query) do |q| + list_for_account(account_id, q, opts: opts) + end + enum.each(&block) + end + + # GET /v2/accounts/{id}/transactions/export + # Returns a Transport::RawResponse (file body, Content-Type, filename). + def export(account_id, query = {}, opts: {}) + raise ArgumentError, 'account_id is required' if account_id.nil? || account_id.to_s.empty? + + @transport.request_raw('GET', "accounts/#{account_id}/transactions/export", query: query, opts: opts) + end + + # POST /v2/accounts/{id}/transactions/sync + # body: { count:, cursor:, options: } — all optional per spec. + def sync(account_id, count: nil, cursor: nil, options: nil, opts: {}) + raise ArgumentError, 'account_id is required' if account_id.nil? || account_id.to_s.empty? + + payload = build_sync_payload(count: count, cursor: cursor, options: options) + body = @transport.request('POST', "accounts/#{account_id}/transactions/sync", body: payload, opts: opts) + Models::SyncResult.from_hash(body) + end + + # POST /v2/transactions/sync (legacy, non-nested) + def sync_legacy(count: nil, cursor: nil, options: nil, opts: {}) + payload = build_sync_payload(count: count, cursor: cursor, options: options) + body = @transport.request('POST', 'transactions/sync', body: payload, opts: opts) + Models::SyncResult.from_hash(body) + end + + # GET /v2/transactions/{id} + def get(id, opts: {}) + raise ArgumentError, 'id is required' if id.nil? || id.to_s.empty? + + merged = opts.key?(:cache) ? opts : opts.merge(cache: { ttl: SHOW_CACHE_TTL }) + body = @transport.request('GET', "transactions/#{id}", opts: merged) + Models::Transaction.from_hash(body) + end + + # POST /v2/transactions/bulk + def bulk(account_ids:, page: nil, per_page: nil, limit: nil, offset: nil, opts: {}) + raise ArgumentError, 'account_ids is required and must not be empty' if account_ids.nil? || account_ids.empty? + + payload = { + account_ids: account_ids, + page: page, + per_page: per_page, + limit: limit, + offset: offset + }.compact + body = @transport.request('POST', 'transactions/bulk', body: payload, opts: opts) + Models::BulkResult.from_hash(body) + end + + # GET /v2/transactions/search + # Pass `q:` (required) plus any optional filters as keyword args + # (account_id, limit, offset, status, type, start_date, etc.). + # `opts:` is the transport options hash. + def search(opts: {}, **filters) + filters = filters.transform_keys(&:to_sym) + q_value = filters[:q] + raise ArgumentError, 'q is required' if q_value.nil? || q_value.to_s.empty? + + body = @transport.request('GET', 'transactions/search', query: filters.compact, opts: opts) + Models::SearchResult.from_hash(body) + end + + private + + def build_sync_payload(count:, cursor:, options:) + payload = {} + payload[:count] = count unless count.nil? + payload[:cursor] = cursor unless cursor.nil? + payload[:options] = options unless options.nil? + payload + end + end + end +end diff --git a/packages/ruby/lib/tesote_sdk/version.rb b/packages/ruby/lib/tesote_sdk/version.rb index d0aab90..d57bf80 100644 --- a/packages/ruby/lib/tesote_sdk/version.rb +++ b/packages/ruby/lib/tesote_sdk/version.rb @@ -1,3 +1,3 @@ module TesoteSdk - VERSION = '0.1.0'.freeze + VERSION = '0.2.0'.freeze end diff --git a/packages/ruby/spec/errors_spec.rb b/packages/ruby/spec/errors_spec.rb index cce67df..71e428e 100644 --- a/packages/ruby/spec/errors_spec.rb +++ b/packages/ruby/spec/errors_spec.rb @@ -19,9 +19,33 @@ def fake_response(status:, headers: {}, message: 'Error') 'ACCOUNT_DISABLED' => [403, TesoteSdk::AccountDisabledError], 'HISTORY_SYNC_FORBIDDEN' => [403, TesoteSdk::HistorySyncForbiddenError], 'MUTATION_CONFLICT' => [409, TesoteSdk::MutationDuringPaginationError], + 'SYNC_IN_PROGRESS' => [409, TesoteSdk::SyncInProgressError], + 'INVALID_ORDER_STATE' => [409, TesoteSdk::InvalidOrderStateError], 'UNPROCESSABLE_CONTENT' => [422, TesoteSdk::UnprocessableContentError], 'INVALID_DATE_RANGE' => [422, TesoteSdk::InvalidDateRangeError], - 'RATE_LIMIT_EXCEEDED' => [429, TesoteSdk::RateLimitExceededError] + 'MISSING_DATE_RANGE' => [422, TesoteSdk::MissingDateRangeError], + 'INVALID_CURSOR' => [422, TesoteSdk::InvalidCursorError], + 'INVALID_COUNT' => [422, TesoteSdk::InvalidCountError], + 'INVALID_LIMIT' => [422, TesoteSdk::InvalidLimitError], + 'INVALID_QUERY' => [422, TesoteSdk::InvalidQueryError], + 'VALIDATION_ERROR' => [400, TesoteSdk::ValidationError], + 'BATCH_VALIDATION_ERROR' => [400, TesoteSdk::BatchValidationError], + 'BANK_SUBMISSION_ERROR' => [422, TesoteSdk::BankSubmissionError], + 'RATE_LIMIT_EXCEEDED' => [429, TesoteSdk::RateLimitExceededError], + 'SYNC_RATE_LIMIT_EXCEEDED' => [429, TesoteSdk::SyncRateLimitExceededError], + 'ACCOUNT_NOT_FOUND' => [404, TesoteSdk::AccountNotFoundError], + 'TRANSACTION_NOT_FOUND' => [404, TesoteSdk::TransactionNotFoundError], + 'SYNC_SESSION_NOT_FOUND' => [404, TesoteSdk::SyncSessionNotFoundError], + 'PAYMENT_METHOD_NOT_FOUND' => [404, TesoteSdk::PaymentMethodNotFoundError], + 'TRANSACTION_ORDER_NOT_FOUND' => [404, TesoteSdk::TransactionOrderNotFoundError], + 'BATCH_NOT_FOUND' => [404, TesoteSdk::BatchNotFoundError], + 'CATEGORY_NOT_FOUND' => [404, TesoteSdk::CategoryNotFoundError], + 'COUNTERPARTY_NOT_FOUND' => [404, TesoteSdk::CounterpartyNotFoundError], + 'LEGAL_ENTITY_NOT_FOUND' => [404, TesoteSdk::LegalEntityNotFoundError], + 'WEBHOOK_NOT_FOUND' => [404, TesoteSdk::WebhookNotFoundError], + 'BANK_CONNECTION_NOT_FOUND' => [404, TesoteSdk::BankConnectionNotFoundError], + 'BANK_UNDER_MAINTENANCE' => [503, TesoteSdk::BankUnderMaintenanceError], + 'INTERNAL_ERROR' => [500, TesoteSdk::InternalServerError] }.each do |code, (status, klass)| it "maps error_code #{code} → #{klass}" do response = fake_response(status: status, headers: { 'X-Request-Id' => 'req_1' }) diff --git a/packages/ruby/spec/v1_accounts_spec.rb b/packages/ruby/spec/v1_accounts_spec.rb new file mode 100644 index 0000000..b3764a5 --- /dev/null +++ b/packages/ruby/spec/v1_accounts_spec.rb @@ -0,0 +1,112 @@ +require 'spec_helper' + +RSpec.describe TesoteSdk::V1::Accounts do + let(:api_key) { 'test_api_key' } + let(:base_url) { 'https://equipo.tesote.com/api' } + let(:client) { TesoteSdk::V1::Client.new(api_key: api_key, base_url: base_url, base_delay: 0.0, max_delay: 0.0) } + + describe '#list' do + it 'returns AccountList of typed Account models' do + payload = { + total: 1, + accounts: [{ + id: 'a_1', + name: 'Checking', + data: { masked_account_number: '1234', currency: 'VES' }, + bank: { name: 'Banesco' }, + legal_entity: { id: 'le_1', legal_name: 'Acme' }, + tesote_created_at: '2026-04-01T00:00:00Z', + tesote_updated_at: '2026-04-02T00:00:00Z' + }], + pagination: { current_page: 1, per_page: 50, total_pages: 1, total_count: 1 } + } + stub_request(:get, "#{base_url}/v1/accounts") + .to_return(status: 200, body: payload.to_json) + + result = client.accounts.list + expect(result).to be_a(TesoteSdk::Models::AccountList) + expect(result.total).to eq(1) + expect(result.accounts.size).to eq(1) + acct = result.accounts.first + expect(acct).to be_a(TesoteSdk::Models::Account) + expect(acct.id).to eq('a_1') + expect(acct.bank).to be_a(TesoteSdk::Models::Bank) + expect(acct.bank.name).to eq('Banesco') + end + end + + describe '#get' do + it 'returns Account model' do + stub_request(:get, "#{base_url}/v1/accounts/a_1") + .to_return(status: 200, + body: { id: 'a_1', name: 'Checking', data: { currency: 'VES' }, bank: { name: 'B' } }.to_json) + result = client.accounts.get('a_1') + expect(result).to be_a(TesoteSdk::Models::Account) + expect(result.id).to eq('a_1') + expect(result.data.currency).to eq('VES') + end + + it 'maps 404 ACCOUNT_NOT_FOUND to AccountNotFoundError' do + stub_request(:get, "#{base_url}/v1/accounts/missing") + .to_return(status: 404, body: { error: 'nope', error_code: 'ACCOUNT_NOT_FOUND' }.to_json) + expect { client.accounts.get('missing') }.to raise_error(TesoteSdk::AccountNotFoundError) + end + + it 'raises ArgumentError on blank id' do + expect { client.accounts.get('') }.to raise_error(ArgumentError) + end + end + + describe '#list_transactions' do + it 'returns TransactionList with cursor pagination fields' do + payload = { + total: 2, + transactions: [ + { id: 't_1', status: 'posted', + data: { amount_cents: 100, currency: 'VES', description: 'a', transaction_date: '2026-04-01' }, + tesote_imported_at: '2026-04-01', tesote_updated_at: '2026-04-01', + transaction_categories: [], counterparty: { name: 'Vendor' } } + ], + pagination: { has_more: true, per_page: 50, after_id: 't_1', before_id: 't_1' } + } + stub_request(:get, "#{base_url}/v1/accounts/a_1/transactions") + .with(query: hash_including('start_date' => '2026-04-01')) + .to_return(status: 200, body: payload.to_json) + + result = client.accounts.list_transactions('a_1', { start_date: '2026-04-01' }) + expect(result).to be_a(TesoteSdk::Models::TransactionList) + expect(result.transactions.first.counterparty.name).to eq('Vendor') + expect(result.pagination.has_more).to eq(true) + end + + it 'maps INVALID_DATE_RANGE to InvalidDateRangeError' do + stub_request(:get, "#{base_url}/v1/accounts/a_1/transactions") + .to_return(status: 422, body: { error: 'bad', error_code: 'INVALID_DATE_RANGE' }.to_json) + expect { client.accounts.list_transactions('a_1') }.to raise_error(TesoteSdk::InvalidDateRangeError) + end + end + + describe '#each_transaction_page' do + it 'walks cursor pages until has_more is false' do + page1 = { + total: 2, + transactions: [{ id: 't_1', status: 'posted', data: {}, tesote_imported_at: nil, tesote_updated_at: nil, + transaction_categories: [], counterparty: nil }], + pagination: { has_more: true, per_page: 1, after_id: 't_1', before_id: 't_1' } + }.to_json + page2 = { + total: 2, + transactions: [{ id: 't_2', status: 'posted', data: {}, tesote_imported_at: nil, tesote_updated_at: nil, + transaction_categories: [], counterparty: nil }], + pagination: { has_more: false, per_page: 1, after_id: 't_2', before_id: 't_2' } + }.to_json + + stub_request(:get, %r{/v1/accounts/a_1/transactions}) + .to_return({ status: 200, body: page1 }, { status: 200, body: page2 }) + + pages = client.accounts.each_transaction_page('a_1').to_a + expect(pages.size).to eq(2) + expect(pages.last.transactions.first.id).to eq('t_2') + end + end +end diff --git a/packages/ruby/spec/v1_status_spec.rb b/packages/ruby/spec/v1_status_spec.rb new file mode 100644 index 0000000..a676fb8 --- /dev/null +++ b/packages/ruby/spec/v1_status_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper' + +RSpec.describe TesoteSdk::V1::Status do + let(:api_key) { 'test_api_key' } + let(:base_url) { 'https://equipo.tesote.com/api' } + let(:client) { TesoteSdk::V1::Client.new(api_key: api_key, base_url: base_url, base_delay: 0.0, max_delay: 0.0) } + + describe '#status' do + it 'GETs /status (unversioned)' do + stub_request(:get, "#{base_url}/status") + .to_return(status: 200, body: { status: 'ok', authenticated: false }.to_json) + + result = client.status.status + expect(result).to be_a(TesoteSdk::Models::StatusResult) + expect(result.status).to eq('ok') + expect(result.authenticated).to eq(false) + end + end + + describe '#whoami' do + it 'GETs /whoami (unversioned) and returns Whoami' do + stub_request(:get, "#{base_url}/whoami") + .to_return(status: 200, body: { client: { id: 'c_1', name: 'Acme', type: 'workspace' } }.to_json) + + result = client.status.whoami + expect(result).to be_a(TesoteSdk::Models::Whoami) + expect(result.client['id']).to eq('c_1') + end + + it 'maps 401 UNAUTHORIZED to UnauthorizedError' do + stub_request(:get, "#{base_url}/whoami") + .to_return(status: 401, body: { error: 'no auth', error_code: 'UNAUTHORIZED' }.to_json) + + expect { client.status.whoami }.to raise_error(TesoteSdk::UnauthorizedError) + end + end +end diff --git a/packages/ruby/spec/v1_transactions_spec.rb b/packages/ruby/spec/v1_transactions_spec.rb new file mode 100644 index 0000000..6631036 --- /dev/null +++ b/packages/ruby/spec/v1_transactions_spec.rb @@ -0,0 +1,50 @@ +require 'spec_helper' + +RSpec.describe TesoteSdk::V1::Transactions do + let(:api_key) { 'test_api_key' } + let(:base_url) { 'https://equipo.tesote.com/api' } + let(:client) { TesoteSdk::V1::Client.new(api_key: api_key, base_url: base_url, base_delay: 0.0, max_delay: 0.0) } + + describe '#get' do + it 'returns a typed Transaction' do + payload = { + id: 't_1', + status: 'posted', + data: { amount_cents: 1000, currency: 'VES', description: 'lunch', transaction_date: '2026-04-01' }, + tesote_imported_at: '2026-04-01T00:00:00Z', + tesote_updated_at: '2026-04-01T00:00:00Z', + transaction_categories: [{ name: 'food', external_category_code: 'FOOD', + created_at: '2026-04-01', updated_at: '2026-04-01' }], + counterparty: { name: 'Cafe' } + } + stub_request(:get, "#{base_url}/v1/transactions/t_1") + .to_return(status: 200, body: payload.to_json) + + result = client.transactions.get('t_1') + expect(result).to be_a(TesoteSdk::Models::Transaction) + expect(result.data).to be_a(TesoteSdk::Models::TransactionData) + expect(result.data.amount_cents).to eq(1000) + expect(result.transaction_categories.first.name).to eq('food') + end + + it 'maps 404 TRANSACTION_NOT_FOUND to TransactionNotFoundError' do + stub_request(:get, "#{base_url}/v1/transactions/missing") + .to_return(status: 404, body: { error_code: 'TRANSACTION_NOT_FOUND', error: 'nope' }.to_json) + expect { client.transactions.get('missing') }.to raise_error(TesoteSdk::TransactionNotFoundError) + end + + it 'raises ArgumentError on blank id' do + expect { client.transactions.get(nil) }.to raise_error(ArgumentError) + end + end + + describe '#list_for_account' do + it 'returns a TransactionList' do + stub_request(:get, "#{base_url}/v1/accounts/a_1/transactions") + .to_return(status: 200, body: { total: 0, transactions: [], pagination: { has_more: false } }.to_json) + result = client.transactions.list_for_account('a_1') + expect(result).to be_a(TesoteSdk::Models::TransactionList) + expect(result.transactions).to eq([]) + end + end +end diff --git a/packages/ruby/spec/v2_accounts_spec.rb b/packages/ruby/spec/v2_accounts_spec.rb new file mode 100644 index 0000000..ba58049 --- /dev/null +++ b/packages/ruby/spec/v2_accounts_spec.rb @@ -0,0 +1,77 @@ +require 'spec_helper' + +RSpec.describe TesoteSdk::V2::Accounts do + let(:base_url) { 'https://equipo.tesote.com/api' } + let(:client) { TesoteSdk::V2::Client.new(api_key: 'k', base_url: base_url, base_delay: 0.0, max_delay: 0.0) } + + describe '#list' do + it 'returns AccountList' do + stub_request(:get, "#{base_url}/v2/accounts") + .to_return(status: 200, body: { total: 0, accounts: [], pagination: {} }.to_json) + expect(client.accounts.list).to be_a(TesoteSdk::Models::AccountList) + end + end + + describe '#get' do + it 'returns an Account' do + stub_request(:get, "#{base_url}/v2/accounts/a_1") + .to_return(status: 200, body: { id: 'a_1', name: 'X', data: {}, bank: {} }.to_json) + expect(client.accounts.get('a_1')).to be_a(TesoteSdk::Models::Account) + end + + it 'maps 404 ACCOUNT_NOT_FOUND' do + stub_request(:get, "#{base_url}/v2/accounts/missing") + .to_return(status: 404, body: { error_code: 'ACCOUNT_NOT_FOUND' }.to_json) + expect { client.accounts.get('missing') }.to raise_error(TesoteSdk::AccountNotFoundError) + end + end + + describe '#sync' do + it 'POSTs and returns SyncStartResult' do + stub_request(:post, "#{base_url}/v2/accounts/a_1/sync") + .with(headers: { 'Content-Type' => 'application/json' }) + .to_return(status: 202, body: { message: 'started', sync_session_id: 'ss_1', + status: 'pending', started_at: '2026-04-28' }.to_json) + result = client.accounts.sync('a_1') + expect(result).to be_a(TesoteSdk::Models::SyncStartResult) + expect(result.sync_session_id).to eq('ss_1') + end + + it 'auto-generates an Idempotency-Key for the POST' do + header_seen = nil + stub_request(:post, "#{base_url}/v2/accounts/a_1/sync") + .with do |req| + header_seen = req.headers['Idempotency-Key'] + true + end + .to_return(status: 202, body: { message: 's', sync_session_id: 'ss', status: 'pending', + started_at: 'now' }.to_json) + client.accounts.sync('a_1') + expect(header_seen).to match(/\A[0-9a-f-]{36}\z/i) + end + + it 'maps 409 SYNC_IN_PROGRESS' do + stub_request(:post, "#{base_url}/v2/accounts/a_1/sync") + .to_return(status: 409, body: { error_code: 'SYNC_IN_PROGRESS', error: 'busy' }.to_json) + expect { client.accounts.sync('a_1') }.to raise_error(TesoteSdk::SyncInProgressError) + end + + it 'maps 503 BANK_UNDER_MAINTENANCE' do + stub_request(:post, "#{base_url}/v2/accounts/a_1/sync") + .to_return(status: 503, body: { error_code: 'BANK_UNDER_MAINTENANCE' }.to_json, + headers: { 'Retry-After' => '120' }) + tight = TesoteSdk::V2::Client.new(api_key: 'k', base_url: base_url, max_attempts: 1, + base_delay: 0.0, max_delay: 0.0, sleeper: ->(_) {}) + expect { tight.accounts.sync('a_1') }.to raise_error(TesoteSdk::BankUnderMaintenanceError) + end + + it 'maps 429 SYNC_RATE_LIMIT_EXCEEDED' do + stub_request(:post, "#{base_url}/v2/accounts/a_1/sync") + .to_return(status: 429, body: { error_code: 'SYNC_RATE_LIMIT_EXCEEDED', retry_after: 60 }.to_json, + headers: { 'Retry-After' => '60' }) + tight = TesoteSdk::V2::Client.new(api_key: 'k', base_url: base_url, max_attempts: 1, + base_delay: 0.0, max_delay: 0.0, sleeper: ->(_) {}) + expect { tight.accounts.sync('a_1') }.to raise_error(TesoteSdk::SyncRateLimitExceededError) + end + end +end diff --git a/packages/ruby/spec/v2_batches_spec.rb b/packages/ruby/spec/v2_batches_spec.rb new file mode 100644 index 0000000..5243a0a --- /dev/null +++ b/packages/ruby/spec/v2_batches_spec.rb @@ -0,0 +1,91 @@ +require 'spec_helper' + +RSpec.describe TesoteSdk::V2::Batches do + let(:base_url) { 'https://equipo.tesote.com/api' } + let(:client) { TesoteSdk::V2::Client.new(api_key: 'k', base_url: base_url, base_delay: 0.0, max_delay: 0.0) } + + describe '#create' do + it 'POSTs orders and returns BatchCreateResult with idempotency-key' do + header_seen = nil + stub_request(:post, "#{base_url}/v2/accounts/a_1/batches") + .with do |req| + header_seen = req.headers['Idempotency-Key'] + req.headers['Content-Type'] == 'application/json' + end + .to_return(status: 201, + body: { batch_id: 'b_1', + orders: [{ id: 'to_1', status: 'draft', batch_id: 'b_1' }], + errors: [] }.to_json) + orders = [{ amount: '1.00', currency: 'VES', beneficiary: { name: 'X' } }] + result = client.batches.create('a_1', orders: orders) + expect(result).to be_a(TesoteSdk::Models::BatchCreateResult) + expect(result.batch_id).to eq('b_1') + expect(result.orders.first).to be_a(TesoteSdk::Models::TransactionOrder) + expect(header_seen).to match(/\A[0-9a-f-]{36}\z/i) + end + + it 'maps BATCH_VALIDATION_ERROR' do + stub_request(:post, "#{base_url}/v2/accounts/a_1/batches") + .to_return(status: 400, body: { error_code: 'BATCH_VALIDATION_ERROR' }.to_json) + expect { client.batches.create('a_1', orders: [{ amount: '1' }]) } + .to raise_error(TesoteSdk::BatchValidationError) + end + + it 'raises ArgumentError on empty orders' do + expect { client.batches.create('a_1', orders: []) }.to raise_error(ArgumentError) + end + end + + describe '#get' do + it 'returns BatchSummary' do + stub_request(:get, "#{base_url}/v2/accounts/a_1/batches/b_1") + .to_return(status: 200, body: { batch_id: 'b_1', total_orders: 2, + total_amount_cents: 200, + amount_currency: 'VES', + statuses: { 'draft' => 2 }, + batch_status: 'draft', + created_at: 'now', + orders: [{ id: 'to_1', status: 'draft' }] }.to_json) + summary = client.batches.get('a_1', 'b_1') + expect(summary).to be_a(TesoteSdk::Models::BatchSummary) + expect(summary.orders.first).to be_a(TesoteSdk::Models::TransactionOrder) + end + + it 'maps BATCH_NOT_FOUND' do + stub_request(:get, "#{base_url}/v2/accounts/a_1/batches/missing") + .to_return(status: 404, body: { error_code: 'BATCH_NOT_FOUND' }.to_json) + expect { client.batches.get('a_1', 'missing') }.to raise_error(TesoteSdk::BatchNotFoundError) + end + end + + describe '#approve / #submit / #cancel' do + it 'approve POSTs and returns BatchApproveResult' do + stub_request(:post, "#{base_url}/v2/accounts/a_1/batches/b_1/approve") + .to_return(status: 200, body: { approved: 5, failed: 0 }.to_json) + result = client.batches.approve('a_1', 'b_1') + expect(result).to be_a(TesoteSdk::Models::BatchApproveResult) + expect(result.approved).to eq(5) + end + + it 'submit POSTs token and returns BatchSubmitResult' do + stub_request(:post, "#{base_url}/v2/accounts/a_1/batches/b_1/submit") + .with(body: { token: 'mfa' }.to_json) + .to_return(status: 200, body: { enqueued: 5, failed: 0 }.to_json) + result = client.batches.submit('a_1', 'b_1', token: 'mfa') + expect(result.enqueued).to eq(5) + end + + it 'cancel POSTs and returns BatchCancelResult' do + stub_request(:post, "#{base_url}/v2/accounts/a_1/batches/b_1/cancel") + .to_return(status: 200, body: { cancelled: 3, skipped: 2, errors: [] }.to_json) + result = client.batches.cancel('a_1', 'b_1') + expect(result.cancelled).to eq(3) + end + + it 'maps INVALID_ORDER_STATE on approve' do + stub_request(:post, "#{base_url}/v2/accounts/a_1/batches/b_1/approve") + .to_return(status: 409, body: { error_code: 'INVALID_ORDER_STATE' }.to_json) + expect { client.batches.approve('a_1', 'b_1') }.to raise_error(TesoteSdk::InvalidOrderStateError) + end + end +end diff --git a/packages/ruby/spec/v2_payment_methods_spec.rb b/packages/ruby/spec/v2_payment_methods_spec.rb new file mode 100644 index 0000000..b3ba252 --- /dev/null +++ b/packages/ruby/spec/v2_payment_methods_spec.rb @@ -0,0 +1,77 @@ +require 'spec_helper' + +RSpec.describe TesoteSdk::V2::PaymentMethods do + let(:base_url) { 'https://equipo.tesote.com/api' } + let(:client) { TesoteSdk::V2::Client.new(api_key: 'k', base_url: base_url, base_delay: 0.0, max_delay: 0.0) } + + describe '#list' do + it 'returns OffsetPage of PaymentMethod' do + stub_request(:get, "#{base_url}/v2/payment_methods") + .to_return(status: 200, body: { items: [{ id: 'pm_1', method_type: 'bank_account', currency: 'VES' }], + has_more: false, limit: 50, offset: 0 }.to_json) + page = client.payment_methods.list + expect(page).to be_a(TesoteSdk::Models::OffsetPage) + expect(page.items.first).to be_a(TesoteSdk::Models::PaymentMethod) + end + end + + describe '#get' do + it 'returns PaymentMethod' do + stub_request(:get, "#{base_url}/v2/payment_methods/pm_1") + .to_return(status: 200, body: { id: 'pm_1', method_type: 'bank_account', currency: 'VES', + counterparty: { id: 'cp_1', name: 'X' } }.to_json) + pm = client.payment_methods.get('pm_1') + expect(pm).to be_a(TesoteSdk::Models::PaymentMethod) + expect(pm.counterparty).to be_a(TesoteSdk::Models::Counterparty) + end + + it 'maps PAYMENT_METHOD_NOT_FOUND' do + stub_request(:get, "#{base_url}/v2/payment_methods/missing") + .to_return(status: 404, body: { error_code: 'PAYMENT_METHOD_NOT_FOUND' }.to_json) + expect { client.payment_methods.get('missing') }.to raise_error(TesoteSdk::PaymentMethodNotFoundError) + end + end + + describe '#create' do + it 'POSTs and returns PaymentMethod with Content-Type and Idempotency-Key' do + stub_request(:post, "#{base_url}/v2/payment_methods") + .with(headers: { 'Content-Type' => 'application/json' }) + .to_return(status: 201, body: { id: 'pm_1', method_type: 'bank_account', currency: 'VES' }.to_json) + pm = client.payment_methods.create(payment_method: { method_type: 'bank_account', currency: 'VES' }) + expect(pm).to be_a(TesoteSdk::Models::PaymentMethod) + end + + it 'maps VALIDATION_ERROR' do + stub_request(:post, "#{base_url}/v2/payment_methods") + .to_return(status: 400, body: { error_code: 'VALIDATION_ERROR' }.to_json) + expect { client.payment_methods.create(payment_method: {}) } + .to raise_error(TesoteSdk::ValidationError) + end + end + + describe '#update' do + it 'PATCHes and returns PaymentMethod' do + stub_request(:patch, "#{base_url}/v2/payment_methods/pm_1") + .with(headers: { 'Content-Type' => 'application/json' }, + body: { payment_method: { label: 'New' } }.to_json) + .to_return(status: 200, body: { id: 'pm_1', method_type: 'bank_account', currency: 'VES', + label: 'New' }.to_json) + result = client.payment_methods.update('pm_1', payment_method: { label: 'New' }) + expect(result.label).to eq('New') + end + end + + describe '#delete' do + it 'DELETEs and returns nil on 204' do + stub_request(:delete, "#{base_url}/v2/payment_methods/pm_1") + .to_return(status: 204, body: '') + expect(client.payment_methods.delete('pm_1')).to be_nil + end + + it 'maps 409 VALIDATION_ERROR (in-use)' do + stub_request(:delete, "#{base_url}/v2/payment_methods/pm_1") + .to_return(status: 409, body: { error_code: 'VALIDATION_ERROR' }.to_json) + expect { client.payment_methods.delete('pm_1') }.to raise_error(TesoteSdk::ValidationError) + end + end +end diff --git a/packages/ruby/spec/v2_status_spec.rb b/packages/ruby/spec/v2_status_spec.rb new file mode 100644 index 0000000..c146189 --- /dev/null +++ b/packages/ruby/spec/v2_status_spec.rb @@ -0,0 +1,21 @@ +require 'spec_helper' + +RSpec.describe TesoteSdk::V2::Status do + let(:base_url) { 'https://equipo.tesote.com/api' } + let(:client) { TesoteSdk::V2::Client.new(api_key: 'k', base_url: base_url, base_delay: 0.0, max_delay: 0.0) } + + it 'GETs /v2/status' do + stub_request(:get, "#{base_url}/v2/status") + .to_return(status: 200, body: { status: 'ok', authenticated: false }.to_json) + result = client.status.status + expect(result).to be_a(TesoteSdk::Models::StatusResult) + expect(result.status).to eq('ok') + end + + it 'GETs /v2/whoami' do + stub_request(:get, "#{base_url}/v2/whoami") + .to_return(status: 200, body: { client: { id: 'c_1' } }.to_json) + result = client.status.whoami + expect(result).to be_a(TesoteSdk::Models::Whoami) + end +end diff --git a/packages/ruby/spec/v2_sync_sessions_spec.rb b/packages/ruby/spec/v2_sync_sessions_spec.rb new file mode 100644 index 0000000..43ca7da --- /dev/null +++ b/packages/ruby/spec/v2_sync_sessions_spec.rb @@ -0,0 +1,51 @@ +require 'spec_helper' + +RSpec.describe TesoteSdk::V2::SyncSessions do + let(:base_url) { 'https://equipo.tesote.com/api' } + let(:client) { TesoteSdk::V2::Client.new(api_key: 'k', base_url: base_url, base_delay: 0.0, max_delay: 0.0) } + + describe '#list' do + it 'returns OffsetPage of SyncSession' do + stub_request(:get, "#{base_url}/v2/accounts/a_1/sync_sessions") + .to_return(status: 200, + body: { sync_sessions: [{ id: 'ss_1', status: 'completed', started_at: '2026-04-01' }], + limit: 50, offset: 0, has_more: false }.to_json) + page = client.sync_sessions.list('a_1') + expect(page).to be_a(TesoteSdk::Models::OffsetPage) + expect(page.items.first).to be_a(TesoteSdk::Models::SyncSession) + expect(page.has_more).to eq(false) + end + + it 'maps BANK_CONNECTION_NOT_FOUND' do + stub_request(:get, "#{base_url}/v2/accounts/a_1/sync_sessions") + .to_return(status: 404, body: { error_code: 'BANK_CONNECTION_NOT_FOUND' }.to_json) + expect { client.sync_sessions.list('a_1') }.to raise_error(TesoteSdk::BankConnectionNotFoundError) + end + end + + describe '#get' do + it 'returns a SyncSession' do + stub_request(:get, "#{base_url}/v2/accounts/a_1/sync_sessions/ss_1") + .to_return(status: 200, body: { id: 'ss_1', status: 'completed', started_at: 'now' }.to_json) + expect(client.sync_sessions.get('a_1', 'ss_1')).to be_a(TesoteSdk::Models::SyncSession) + end + + it 'maps SYNC_SESSION_NOT_FOUND' do + stub_request(:get, "#{base_url}/v2/accounts/a_1/sync_sessions/missing") + .to_return(status: 404, body: { error_code: 'SYNC_SESSION_NOT_FOUND' }.to_json) + expect { client.sync_sessions.get('a_1', 'missing') }.to raise_error(TesoteSdk::SyncSessionNotFoundError) + end + end + + describe '#each_page (offset)' do + it 'walks until has_more false' do + page1 = { sync_sessions: [{ id: 'ss_1' }], limit: 1, offset: 0, has_more: true }.to_json + page2 = { sync_sessions: [{ id: 'ss_2' }], limit: 1, offset: 1, has_more: false }.to_json + stub_request(:get, %r{/v2/accounts/a_1/sync_sessions}) + .to_return({ status: 200, body: page1 }, { status: 200, body: page2 }) + + pages = client.sync_sessions.each_page('a_1', {}, page_size: 1).to_a + expect(pages.size).to eq(2) + end + end +end diff --git a/packages/ruby/spec/v2_transaction_orders_spec.rb b/packages/ruby/spec/v2_transaction_orders_spec.rb new file mode 100644 index 0000000..23cd1e8 --- /dev/null +++ b/packages/ruby/spec/v2_transaction_orders_spec.rb @@ -0,0 +1,99 @@ +require 'spec_helper' + +RSpec.describe TesoteSdk::V2::TransactionOrders do + let(:base_url) { 'https://equipo.tesote.com/api' } + let(:client) { TesoteSdk::V2::Client.new(api_key: 'k', base_url: base_url, base_delay: 0.0, max_delay: 0.0) } + + describe '#list' do + it 'returns OffsetPage of TransactionOrder' do + stub_request(:get, "#{base_url}/v2/accounts/a_1/transaction_orders") + .to_return(status: 200, body: { items: [{ id: 'to_1', status: 'draft' }], + has_more: false, limit: 50, offset: 0 }.to_json) + page = client.transaction_orders.list('a_1') + expect(page).to be_a(TesoteSdk::Models::OffsetPage) + expect(page.items.first).to be_a(TesoteSdk::Models::TransactionOrder) + end + end + + describe '#get' do + it 'returns TransactionOrder' do + stub_request(:get, "#{base_url}/v2/accounts/a_1/transaction_orders/to_1") + .to_return(status: 200, body: { id: 'to_1', status: 'draft', + latest_attempt: { id: 'la_1', status: 'pending', attempt_number: 1 } }.to_json) + result = client.transaction_orders.get('a_1', 'to_1') + expect(result.latest_attempt).to be_a(TesoteSdk::Models::LatestAttempt) + end + + it 'maps TRANSACTION_ORDER_NOT_FOUND' do + stub_request(:get, "#{base_url}/v2/accounts/a_1/transaction_orders/missing") + .to_return(status: 404, body: { error_code: 'TRANSACTION_ORDER_NOT_FOUND' }.to_json) + expect { client.transaction_orders.get('a_1', 'missing') } + .to raise_error(TesoteSdk::TransactionOrderNotFoundError) + end + end + + describe '#create' do + it 'POSTs and returns TransactionOrder; sends Idempotency-Key header' do + header_seen = nil + stub_request(:post, "#{base_url}/v2/accounts/a_1/transaction_orders") + .with do |req| + header_seen = req.headers['Idempotency-Key'] + true + end + .to_return(status: 201, body: { id: 'to_1', status: 'draft' }.to_json) + order = { amount: '10.00', currency: 'VES', description: 'test', beneficiary: { name: 'X' } } + result = client.transaction_orders.create('a_1', order: order) + expect(result).to be_a(TesoteSdk::Models::TransactionOrder) + expect(header_seen).to match(/\A[0-9a-f-]{36}\z/i) + end + + it 'forwards a caller-supplied idempotency_key opt to the header' do + stub_request(:post, "#{base_url}/v2/accounts/a_1/transaction_orders") + .with(headers: { 'Idempotency-Key' => 'caller-key' }) + .to_return(status: 201, body: { id: 'to_1', status: 'draft' }.to_json) + client.transaction_orders.create('a_1', + order: { amount: '1.00', currency: 'VES' }, + opts: { idempotency_key: 'caller-key' }) + end + + it 'maps VALIDATION_ERROR' do + stub_request(:post, "#{base_url}/v2/accounts/a_1/transaction_orders") + .to_return(status: 400, body: { error_code: 'VALIDATION_ERROR', error: 'bad' }.to_json) + expect { client.transaction_orders.create('a_1', order: {}) } + .to raise_error(TesoteSdk::ValidationError) + end + + it 'maps 415 to plain ApiError when Content-Type missing on server' do + stub_request(:post, "#{base_url}/v2/accounts/a_1/transaction_orders") + .to_return(status: 415, body: { error: 'unsupported' }.to_json) + expect { client.transaction_orders.create('a_1', order: {}) } + .to raise_error(TesoteSdk::ApiError) { |err| expect(err.http_status).to eq(415) } + end + end + + describe '#submit' do + it 'POSTs to submit endpoint with token' do + stub_request(:post, "#{base_url}/v2/accounts/a_1/transaction_orders/to_1/submit") + .with(body: { token: 'mfa-1' }.to_json) + .to_return(status: 202, body: { id: 'to_1', status: 'processing' }.to_json) + result = client.transaction_orders.submit('a_1', 'to_1', token: 'mfa-1') + expect(result.status).to eq('processing') + end + + it 'maps INVALID_ORDER_STATE' do + stub_request(:post, "#{base_url}/v2/accounts/a_1/transaction_orders/to_1/submit") + .to_return(status: 409, body: { error_code: 'INVALID_ORDER_STATE' }.to_json) + expect { client.transaction_orders.submit('a_1', 'to_1') } + .to raise_error(TesoteSdk::InvalidOrderStateError) + end + end + + describe '#cancel' do + it 'POSTs to cancel endpoint' do + stub_request(:post, "#{base_url}/v2/accounts/a_1/transaction_orders/to_1/cancel") + .to_return(status: 200, body: { id: 'to_1', status: 'cancelled' }.to_json) + result = client.transaction_orders.cancel('a_1', 'to_1') + expect(result.status).to eq('cancelled') + end + end +end diff --git a/packages/ruby/spec/v2_transactions_spec.rb b/packages/ruby/spec/v2_transactions_spec.rb new file mode 100644 index 0000000..128a2a0 --- /dev/null +++ b/packages/ruby/spec/v2_transactions_spec.rb @@ -0,0 +1,152 @@ +require 'spec_helper' + +RSpec.describe TesoteSdk::V2::Transactions do + let(:base_url) { 'https://equipo.tesote.com/api' } + let(:client) { TesoteSdk::V2::Client.new(api_key: 'k', base_url: base_url, base_delay: 0.0, max_delay: 0.0) } + + describe '#list_for_account' do + it 'returns TransactionList' do + stub_request(:get, "#{base_url}/v2/accounts/a_1/transactions") + .to_return(status: 200, body: { total: 0, transactions: [], pagination: { has_more: false } }.to_json) + expect(client.transactions.list_for_account('a_1')).to be_a(TesoteSdk::Models::TransactionList) + end + + it 'maps INVALID_DATE_RANGE' do + stub_request(:get, "#{base_url}/v2/accounts/a_1/transactions") + .to_return(status: 422, body: { error_code: 'INVALID_DATE_RANGE' }.to_json) + expect { client.transactions.list_for_account('a_1') }.to raise_error(TesoteSdk::InvalidDateRangeError) + end + end + + describe '#each_page_for_account (cursor)' do + it 'walks pages until has_more false' do + page1 = { total: 2, transactions: [{ id: 't_1' }], + pagination: { has_more: true, after_id: 't_1' } }.to_json + page2 = { total: 2, transactions: [{ id: 't_2' }], + pagination: { has_more: false, after_id: 't_2' } }.to_json + + stub_request(:get, %r{/v2/accounts/a_1/transactions(?:\?|\z)}) + .to_return({ status: 200, body: page1 }, { status: 200, body: page2 }) + + pages = client.transactions.each_page_for_account('a_1').to_a + expect(pages.size).to eq(2) + end + end + + describe '#export' do + it 'returns RawResponse with CSV body' do + stub_request(:get, "#{base_url}/v2/accounts/a_1/transactions/export") + .with(query: hash_including('format' => 'csv')) + .to_return(status: 200, body: "id,date\n1,2026-04-01\n", + headers: { 'Content-Type' => 'text/csv', + 'Content-Disposition' => 'attachment; filename=tx_a_1_now.csv', + 'X-Request-Id' => 'req_export' }) + raw = client.transactions.export('a_1', { format: 'csv' }) + expect(raw).to be_a(TesoteSdk::Transport::RawResponse) + expect(raw.body).to include('id,date') + expect(raw.content_type).to eq('text/csv') + expect(raw.content_disposition).to include('attachment') + end + + it 'maps UNPROCESSABLE_CONTENT for export' do + stub_request(:get, "#{base_url}/v2/accounts/a_1/transactions/export") + .to_return(status: 422, body: { error_code: 'UNPROCESSABLE_CONTENT' }.to_json) + expect { client.transactions.export('a_1') }.to raise_error(TesoteSdk::UnprocessableContentError) + end + end + + describe '#sync' do + it 'POSTs to /accounts/{id}/transactions/sync and returns SyncResult' do + payload = { added: [{ transaction_id: 't_1', account_id: 'a_1', amount: 1.0, date: '2026-04-01', + name: 'lunch', pending: false }], + modified: [], removed: [], next_cursor: 'c1', has_more: false } + stub_request(:post, "#{base_url}/v2/accounts/a_1/transactions/sync") + .with(headers: { 'Content-Type' => 'application/json' }) + .to_return(status: 200, body: payload.to_json) + result = client.transactions.sync('a_1', count: 100) + expect(result).to be_a(TesoteSdk::Models::SyncResult) + expect(result.added.first).to be_a(TesoteSdk::Models::SyncTransaction) + expect(result.next_cursor).to eq('c1') + end + + it 'maps INVALID_COUNT' do + stub_request(:post, "#{base_url}/v2/accounts/a_1/transactions/sync") + .to_return(status: 422, body: { error_code: 'INVALID_COUNT' }.to_json) + expect { client.transactions.sync('a_1') }.to raise_error(TesoteSdk::InvalidCountError) + end + + it 'maps INVALID_CURSOR' do + stub_request(:post, "#{base_url}/v2/accounts/a_1/transactions/sync") + .to_return(status: 422, body: { error_code: 'INVALID_CURSOR' }.to_json) + expect { client.transactions.sync('a_1', cursor: 'x') }.to raise_error(TesoteSdk::InvalidCursorError) + end + + it 'maps HISTORY_SYNC_FORBIDDEN' do + stub_request(:post, "#{base_url}/v2/accounts/a_1/transactions/sync") + .to_return(status: 403, body: { error_code: 'HISTORY_SYNC_FORBIDDEN' }.to_json) + expect { client.transactions.sync('a_1') }.to raise_error(TesoteSdk::HistorySyncForbiddenError) + end + end + + describe '#sync_legacy' do + it 'POSTs to /transactions/sync (legacy non-nested)' do + stub_request(:post, "#{base_url}/v2/transactions/sync") + .to_return(status: 200, body: { added: [], modified: [], removed: [], next_cursor: nil, + has_more: false }.to_json) + expect(client.transactions.sync_legacy).to be_a(TesoteSdk::Models::SyncResult) + end + end + + describe '#get' do + it 'returns Transaction' do + stub_request(:get, "#{base_url}/v2/transactions/t_1") + .to_return(status: 200, body: { id: 't_1', status: 'posted', data: {}, + transaction_categories: [] }.to_json) + expect(client.transactions.get('t_1')).to be_a(TesoteSdk::Models::Transaction) + end + + it 'maps TRANSACTION_NOT_FOUND' do + stub_request(:get, "#{base_url}/v2/transactions/t_x") + .to_return(status: 404, body: { error_code: 'TRANSACTION_NOT_FOUND' }.to_json) + expect { client.transactions.get('t_x') }.to raise_error(TesoteSdk::TransactionNotFoundError) + end + end + + describe '#bulk' do + it 'POSTs to /transactions/bulk and parses BulkResult' do + payload = { bulk_results: [{ account_id: 'a_1', transactions: [], + pagination: { has_more: false } }] } + stub_request(:post, "#{base_url}/v2/transactions/bulk") + .with(body: hash_including('account_ids' => ['a_1'])) + .to_return(status: 200, body: payload.to_json) + result = client.transactions.bulk(account_ids: ['a_1']) + expect(result).to be_a(TesoteSdk::Models::BulkResult) + expect(result.bulk_results.first.account_id).to eq('a_1') + end + + it 'raises ArgumentError on empty account_ids' do + expect { client.transactions.bulk(account_ids: []) }.to raise_error(ArgumentError) + end + + it 'maps UNPROCESSABLE_CONTENT' do + stub_request(:post, "#{base_url}/v2/transactions/bulk") + .to_return(status: 422, body: { error_code: 'UNPROCESSABLE_CONTENT' }.to_json) + expect { client.transactions.bulk(account_ids: ['a_1']) } + .to raise_error(TesoteSdk::UnprocessableContentError) + end + end + + describe '#search' do + it 'returns SearchResult' do + stub_request(:get, "#{base_url}/v2/transactions/search") + .with(query: hash_including('q' => 'cafe')) + .to_return(status: 200, body: { transactions: [], total: 0 }.to_json) + result = client.transactions.search(q: 'cafe') + expect(result).to be_a(TesoteSdk::Models::SearchResult) + end + + it 'raises ArgumentError when q is missing' do + expect { client.transactions.search }.to raise_error(ArgumentError) + end + end +end From 3d63dad58b6ccb9e084e69fc4aa64ecf4cd681e8 Mon Sep 17 00:00:00 2001 From: sebi Date: Tue, 28 Apr 2026 19:44:12 -0500 Subject: [PATCH 07/10] ts: implement full v1+v2 surface (0.2.0) 35 endpoints (6 v1 + 29 v2) wired against the Rails-controller-derived spec. Adds resource clients on V1Client/V2Client (Accounts, Transactions, Status, plus v2 SyncSessions, TransactionOrders, Batches, PaymentMethods), typed model interfaces for every response shape, 19 new typed error classes per error_code, cursor + offset listAll* async iterators, and raw-text transport bodies for CSV/JSON export. typecheck/biome lint/build clean, 106/106 vitest tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/ts/CHANGELOG.md | 23 ++ packages/ts/package.json | 2 +- packages/ts/src/errors.ts | 88 ++++++- packages/ts/src/index.ts | 78 ++++++ packages/ts/src/models/account.ts | 48 ++++ packages/ts/src/models/batch.ts | 58 +++++ packages/ts/src/models/bulk.ts | 16 ++ packages/ts/src/models/pagination.ts | 11 + packages/ts/src/models/payment_method.ts | 57 +++++ packages/ts/src/models/search.ts | 10 + packages/ts/src/models/status.ts | 20 ++ packages/ts/src/models/sync_session.ts | 44 ++++ packages/ts/src/models/sync_transaction.ts | 33 +++ packages/ts/src/models/transaction.ts | 53 ++++ packages/ts/src/models/transaction_order.ts | 88 +++++++ packages/ts/src/transport.ts | 10 +- packages/ts/src/transport_internals.ts | 23 +- packages/ts/src/v1/accounts.ts | 37 +-- packages/ts/src/v1/index.ts | 13 +- packages/ts/src/v1/status.ts | 21 +- packages/ts/src/v1/transactions.ts | 62 +++-- packages/ts/src/v2/accounts.ts | 34 ++- packages/ts/src/v2/batches.ts | 96 +++++++- packages/ts/src/v2/index.ts | 21 ++ packages/ts/src/v2/payment_methods.ts | 114 +++++++-- packages/ts/src/v2/status.ts | 21 +- packages/ts/src/v2/sync_sessions.ts | 50 +++- packages/ts/src/v2/transaction_orders.ts | 124 ++++++++-- packages/ts/src/v2/transactions.ts | 204 ++++++++++++++-- packages/ts/test/accounts.test.ts | 164 +++++++++++++ packages/ts/test/batches.test.ts | 134 ++++++++++ packages/ts/test/helpers.ts | 76 ++++++ packages/ts/test/paymentMethods.test.ts | 122 ++++++++++ packages/ts/test/status.test.ts | 57 +++++ packages/ts/test/syncSessions.test.ts | 87 +++++++ packages/ts/test/transactionOrders.test.ts | 173 +++++++++++++ packages/ts/test/transactions.test.ts | 255 ++++++++++++++++++++ packages/ts/test/transport.test.ts | 2 +- 38 files changed, 2396 insertions(+), 133 deletions(-) create mode 100644 packages/ts/src/models/account.ts create mode 100644 packages/ts/src/models/batch.ts create mode 100644 packages/ts/src/models/bulk.ts create mode 100644 packages/ts/src/models/pagination.ts create mode 100644 packages/ts/src/models/payment_method.ts create mode 100644 packages/ts/src/models/search.ts create mode 100644 packages/ts/src/models/status.ts create mode 100644 packages/ts/src/models/sync_session.ts create mode 100644 packages/ts/src/models/sync_transaction.ts create mode 100644 packages/ts/src/models/transaction.ts create mode 100644 packages/ts/src/models/transaction_order.ts create mode 100644 packages/ts/test/accounts.test.ts create mode 100644 packages/ts/test/batches.test.ts create mode 100644 packages/ts/test/helpers.ts create mode 100644 packages/ts/test/paymentMethods.test.ts create mode 100644 packages/ts/test/status.test.ts create mode 100644 packages/ts/test/syncSessions.test.ts create mode 100644 packages/ts/test/transactionOrders.test.ts create mode 100644 packages/ts/test/transactions.test.ts diff --git a/packages/ts/CHANGELOG.md b/packages/ts/CHANGELOG.md index 43c1509..01ec197 100644 --- a/packages/ts/CHANGELOG.md +++ b/packages/ts/CHANGELOG.md @@ -4,6 +4,29 @@ All notable changes to `@tesote.com/sdk` are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to semver. +## 0.2.0 - 2026-04-28 + +### Added + +- Full v1+v2 resource surface (35 endpoints): + - v1: `accounts.list/get/listAll`, `transactions.listForAccount/get/listAllForAccount`, + `status.status/whoami`. + - v2: `accounts.list/get/sync/listAll`, `transactions.listForAccount/get/export/sync/syncLegacy/bulk/search/listAllForAccount`, + `syncSessions.list/get/listAll`, `transactionOrders.list/get/create/submit/cancel/listAll`, + `batches.create/get/approve/submit/cancel`, + `paymentMethods.list/get/create/update/delete/listAll`, `status.status/whoami`. +- Typed model interfaces for every payload: `Account`, `Transaction`, `SyncTransaction`, + `SyncResult`, `SyncSession`, `TransactionOrder`, `PaymentMethod`, `BatchSummary`, + `BulkResult`, `SearchResult`, plus pagination envelopes and request inputs. +- Cursor- and offset-pagination async iterators (`listAll*`). +- Typed errors for every API `error_code`: `AccountNotFoundError`, `TransactionNotFoundError`, + `SyncSessionNotFoundError`, `PaymentMethodNotFoundError`, `TransactionOrderNotFoundError`, + `BatchNotFoundError`, `BankConnectionNotFoundError`, `InvalidOrderStateError`, + `SyncInProgressError`, `InvalidCursorError`, `InvalidCountError`, `InvalidLimitError`, + `InvalidQueryError`, `MissingDateRangeError`, `BankSubmissionError`, `ValidationError`, + `BatchValidationError`, `SyncRateLimitExceededError`, `BankUnderMaintenanceError`, + `InternalServerError`, plus a generic `NotFoundError` base. + ## 0.1.1 - 2026-04-28 ### Changed diff --git a/packages/ts/package.json b/packages/ts/package.json index 2476e09..6c9ae0b 100644 --- a/packages/ts/package.json +++ b/packages/ts/package.json @@ -1,6 +1,6 @@ { "name": "@tesote.com/sdk", - "version": "0.1.1", + "version": "0.2.0", "description": "Official TypeScript SDK for the Tesote API (equipo.tesote.com).", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/ts/src/errors.ts b/packages/ts/src/errors.ts index 0ffa26c..c81eebd 100644 --- a/packages/ts/src/errors.ts +++ b/packages/ts/src/errors.ts @@ -80,16 +80,54 @@ export class TesoteError extends Error { /** Server returned a structured API error. */ export class ApiError extends TesoteError {} +// 401 export class UnauthorizedError extends ApiError {} export class ApiKeyRevokedError extends ApiError {} + +// 403 export class WorkspaceSuspendedError extends ApiError {} export class AccountDisabledError extends ApiError {} export class HistorySyncForbiddenError extends ApiError {} + +// 404 +export class NotFoundError extends ApiError {} +export class AccountNotFoundError extends NotFoundError {} +export class TransactionNotFoundError extends NotFoundError {} +export class SyncSessionNotFoundError extends NotFoundError {} +export class PaymentMethodNotFoundError extends NotFoundError {} +export class TransactionOrderNotFoundError extends NotFoundError {} +export class BatchNotFoundError extends NotFoundError {} +export class BankConnectionNotFoundError extends NotFoundError {} + +// 409 export class MutationDuringPaginationError extends ApiError {} +export class InvalidOrderStateError extends ApiError {} +export class SyncInProgressError extends ApiError {} + +// 422 export class UnprocessableContentError extends ApiError {} export class InvalidDateRangeError extends ApiError {} +export class InvalidCursorError extends ApiError {} +export class InvalidCountError extends ApiError {} +export class InvalidLimitError extends ApiError {} +export class InvalidQueryError extends ApiError {} +export class MissingDateRangeError extends ApiError {} +export class BankSubmissionError extends ApiError {} + +// 400 +export class ValidationError extends ApiError {} +export class BatchValidationError extends ApiError {} + +// 429 export class RateLimitExceededError extends ApiError {} +export class SyncRateLimitExceededError extends ApiError {} + +// 503 export class ServiceUnavailableError extends ApiError {} +export class BankUnderMaintenanceError extends ApiError {} + +// 500 +export class InternalServerError extends ApiError {} /** Transport-level failure: no usable HTTP response. */ export class TransportError extends TesoteError {} @@ -110,36 +148,64 @@ interface ApiErrorEnvelope { retry_after?: number; } -const ERROR_CODE_MAP: Record< - string, - new ( - f: Partial & Pick, - ) => ApiError -> = { +type ApiErrorCtor = new ( + f: Partial & Pick, +) => ApiError; + +const ERROR_CODE_MAP: Record = { + // 401 UNAUTHORIZED: UnauthorizedError, API_KEY_REVOKED: ApiKeyRevokedError, + // 403 WORKSPACE_SUSPENDED: WorkspaceSuspendedError, ACCOUNT_DISABLED: AccountDisabledError, HISTORY_SYNC_FORBIDDEN: HistorySyncForbiddenError, + // 404 + ACCOUNT_NOT_FOUND: AccountNotFoundError, + TRANSACTION_NOT_FOUND: TransactionNotFoundError, + SYNC_SESSION_NOT_FOUND: SyncSessionNotFoundError, + PAYMENT_METHOD_NOT_FOUND: PaymentMethodNotFoundError, + TRANSACTION_ORDER_NOT_FOUND: TransactionOrderNotFoundError, + BATCH_NOT_FOUND: BatchNotFoundError, + BANK_CONNECTION_NOT_FOUND: BankConnectionNotFoundError, + // 409 MUTATION_CONFLICT: MutationDuringPaginationError, + INVALID_ORDER_STATE: InvalidOrderStateError, + SYNC_IN_PROGRESS: SyncInProgressError, + // 422 UNPROCESSABLE_CONTENT: UnprocessableContentError, INVALID_DATE_RANGE: InvalidDateRangeError, + INVALID_CURSOR: InvalidCursorError, + INVALID_COUNT: InvalidCountError, + INVALID_LIMIT: InvalidLimitError, + INVALID_QUERY: InvalidQueryError, + MISSING_DATE_RANGE: MissingDateRangeError, + BANK_SUBMISSION_ERROR: BankSubmissionError, + // 400 + VALIDATION_ERROR: ValidationError, + BATCH_VALIDATION_ERROR: BatchValidationError, + // 429 RATE_LIMIT_EXCEEDED: RateLimitExceededError, + SYNC_RATE_LIMIT_EXCEEDED: SyncRateLimitExceededError, + // 503 + BANK_UNDER_MAINTENANCE: BankUnderMaintenanceError, + // 500 + INTERNAL_ERROR: InternalServerError, }; function pickStatusFallback(httpStatus: number): { - cls: new ( - f: Partial & Pick, - ) => ApiError; + cls: ApiErrorCtor; errorCode: string; } { if (httpStatus === 401) return { cls: UnauthorizedError, errorCode: 'UNAUTHORIZED' }; if (httpStatus === 403) return { cls: ApiError, errorCode: 'FORBIDDEN' }; + if (httpStatus === 404) return { cls: NotFoundError, errorCode: 'NOT_FOUND' }; if (httpStatus === 409) return { cls: MutationDuringPaginationError, errorCode: 'MUTATION_CONFLICT' }; if (httpStatus === 422) return { cls: UnprocessableContentError, errorCode: 'UNPROCESSABLE_CONTENT' }; if (httpStatus === 429) return { cls: RateLimitExceededError, errorCode: 'RATE_LIMIT_EXCEEDED' }; + if (httpStatus === 500) return { cls: InternalServerError, errorCode: 'INTERNAL_ERROR' }; if (httpStatus === 503) return { cls: ServiceUnavailableError, errorCode: 'SERVICE_UNAVAILABLE' }; return { cls: ApiError, errorCode: `HTTP_${httpStatus}` }; } @@ -178,9 +244,7 @@ function envelopeFrom(parsed: unknown): ApiErrorEnvelope { export function mapApiError(input: MapApiErrorInput): ApiError { const env = envelopeFrom(input.parsedBody); const code = env.error_code; - let cls: new ( - f: Partial & Pick, - ) => ApiError; + let cls: ApiErrorCtor; let errorCode: string; if (code !== undefined && code in ERROR_CODE_MAP) { const mapped = ERROR_CODE_MAP[code]; diff --git a/packages/ts/src/index.ts b/packages/ts/src/index.ts index 7b33886..93fa87c 100644 --- a/packages/ts/src/index.ts +++ b/packages/ts/src/index.ts @@ -16,21 +16,42 @@ export { V2Client, type V2ClientOptions } from './v2/index.js'; export { ApiError, AccountDisabledError, + AccountNotFoundError, ApiKeyRevokedError, + BankConnectionNotFoundError, + BankSubmissionError, + BankUnderMaintenanceError, + BatchNotFoundError, + BatchValidationError, ConfigError, EndpointRemovedError, HistorySyncForbiddenError, + InternalServerError, + InvalidCountError, + InvalidCursorError, InvalidDateRangeError, + InvalidLimitError, + InvalidOrderStateError, + InvalidQueryError, + MissingDateRangeError, MutationDuringPaginationError, NetworkError, + NotFoundError, + PaymentMethodNotFoundError, RateLimitExceededError, ServiceUnavailableError, + SyncInProgressError, + SyncRateLimitExceededError, + SyncSessionNotFoundError, TesoteError, TimeoutError, TlsError, + TransactionNotFoundError, + TransactionOrderNotFoundError, TransportError, UnauthorizedError, UnprocessableContentError, + ValidationError, WorkspaceSuspendedError, mapApiError, redactBearer, @@ -55,3 +76,60 @@ export { type RetryPolicy, type TransportOptions, } from './transport.js'; + +export type { + Account, + AccountData, + AccountBank, + AccountLegalEntity, + AccountListResponse, + PageBasedPagination, +} from './models/account.js'; +export type { + Transaction, + TransactionData, + TransactionCategory, + TransactionCounterparty, + TransactionListResponse, + CursorPagination, +} from './models/transaction.js'; +export type { + SyncTransaction, + SyncRemoved, + SyncResult, +} from './models/sync_transaction.js'; +export type { + SyncSession, + SyncSessionError, + SyncSessionPerformance, +} from './models/sync_session.js'; +export type { + TransactionOrder, + TransactionOrderStatus, + TransactionOrderSourceAccount, + TransactionOrderDestination, + TransactionOrderFee, + TransactionOrderTesoteTransaction, + TransactionOrderLatestAttempt, + Beneficiary, +} from './models/transaction_order.js'; +export type { + PaymentMethod, + PaymentMethodType, + PaymentMethodDetails, + PaymentMethodCounterparty, + PaymentMethodTesoteAccount, +} from './models/payment_method.js'; +export type { + BatchSummary, + BatchStatus, + BatchStatusCounts, + BatchCreateResponse, + BatchApproveResponse, + BatchSubmitResponse, + BatchCancelResponse, +} from './models/batch.js'; +export type { BulkResult, BulkResponse } from './models/bulk.js'; +export type { SearchResult } from './models/search.js'; +export type { OffsetPaginationResponse } from './models/pagination.js'; +export type { StatusResponse, WhoamiResponse, WhoamiClient } from './models/status.js'; diff --git a/packages/ts/src/models/account.ts b/packages/ts/src/models/account.ts new file mode 100644 index 0000000..d4f3c8a --- /dev/null +++ b/packages/ts/src/models/account.ts @@ -0,0 +1,48 @@ +/** + * Account model — identical between v1 and v2. + * Wire shape uses snake_case; we preserve it on the model. + */ + +export interface AccountBank { + name: string; +} + +export interface AccountLegalEntity { + id: string | null; + legal_name: string | null; +} + +export interface AccountData { + masked_account_number: string; + currency: string; + transactions_data_current_as_of: string | null; + balance_data_current_as_of: string | null; + custom_user_provided_identifier: string | null; + /** Conditional: only present if `display_balances_in_api` is enabled. */ + balance_cents?: string; + /** Conditional: only present if `display_balances_in_api` is enabled. */ + available_balance_cents?: string; +} + +export interface Account { + id: string; + name: string; + data: AccountData; + bank: AccountBank; + legal_entity: AccountLegalEntity; + tesote_created_at: string; + tesote_updated_at: string; +} + +export interface PageBasedPagination { + current_page: number; + per_page: number; + total_pages: number; + total_count: number; +} + +export interface AccountListResponse { + total: number; + accounts: Account[]; + pagination: PageBasedPagination; +} diff --git a/packages/ts/src/models/batch.ts b/packages/ts/src/models/batch.ts new file mode 100644 index 0000000..a4b66a5 --- /dev/null +++ b/packages/ts/src/models/batch.ts @@ -0,0 +1,58 @@ +/** + * Batch — wraps multiple TransactionOrder rows for atomic creation/lifecycle. + */ + +import type { TransactionOrder } from './transaction_order.js'; + +export type BatchStatus = 'draft' | 'mixed' | 'approved' | 'processing' | 'completed' | string; + +export interface BatchStatusCounts { + draft?: number; + pending_approval?: number; + approved?: number; + processing?: number; + completed?: number; + failed?: number; + cancelled?: number; + [k: string]: number | undefined; +} + +export interface BatchSummary { + batch_id: string; + total_orders: number; + total_amount_cents: number; + amount_currency: string; + statuses: BatchStatusCounts; + batch_status: BatchStatus; + created_at: string; + orders: TransactionOrder[]; +} + +export interface BatchCreateError { + /** Index of the order in the request array that failed validation. */ + index: number; + message: string; + details?: unknown; +} + +export interface BatchCreateResponse { + batch_id: string; + orders: TransactionOrder[]; + errors: BatchCreateError[]; +} + +export interface BatchApproveResponse { + approved: number; + failed: number; +} + +export interface BatchSubmitResponse { + enqueued: number; + failed: number; +} + +export interface BatchCancelResponse { + cancelled: number; + skipped: number; + errors: BatchCreateError[]; +} diff --git a/packages/ts/src/models/bulk.ts b/packages/ts/src/models/bulk.ts new file mode 100644 index 0000000..4485707 --- /dev/null +++ b/packages/ts/src/models/bulk.ts @@ -0,0 +1,16 @@ +/** + * BulkResult — POST /v2/transactions/bulk response shape. + * Returns transactions for multiple accounts in a single round-trip. + */ + +import type { CursorPagination, Transaction } from './transaction.js'; + +export interface BulkResult { + account_id: string; + transactions: Transaction[]; + pagination: CursorPagination; +} + +export interface BulkResponse { + bulk_results: BulkResult[]; +} diff --git a/packages/ts/src/models/pagination.ts b/packages/ts/src/models/pagination.ts new file mode 100644 index 0000000..d2e69b5 --- /dev/null +++ b/packages/ts/src/models/pagination.ts @@ -0,0 +1,11 @@ +/** + * Generic offset-pagination response envelope. + * Used by sync_sessions, transaction_orders, payment_methods. + */ + +export interface OffsetPaginationResponse { + items: T[]; + has_more: boolean; + limit: number; + offset: number; +} diff --git a/packages/ts/src/models/payment_method.ts b/packages/ts/src/models/payment_method.ts new file mode 100644 index 0000000..32924e0 --- /dev/null +++ b/packages/ts/src/models/payment_method.ts @@ -0,0 +1,57 @@ +/** + * PaymentMethod — destination/source for transactions. + * `method_type` determines which fields the `details` object carries. + */ + +export type PaymentMethodType = + | 'bank_account' + | 'pago_movil' + | 'wire' + | 'crypto_wallet' + | 'fx_rail' + | 'ach' + | 'eft'; + +export interface PaymentMethodDetails { + bank_code?: string; + account_number?: string; + holder_name?: string; + identification_type?: string | null; + identification_number?: string | null; + /** method_type-specific extras (wallet address, IBAN, routing number, ...). */ + [field: string]: string | number | boolean | null | undefined; +} + +export interface PaymentMethodCounterparty { + id: string; + name: string; +} + +export interface PaymentMethodTesoteAccount { + id: string; + name: string; +} + +export interface PaymentMethod { + id: string; + method_type: PaymentMethodType; + currency: string; + label: string | null; + details: PaymentMethodDetails; + verified: boolean; + verified_at: string | null; + last_used_at: string | null; + /** Mutually exclusive with `tesote_account` — set when the method is a destination. */ + counterparty: PaymentMethodCounterparty | null; + /** Mutually exclusive with `counterparty` — set when the method is a source. */ + tesote_account: PaymentMethodTesoteAccount | null; + created_at: string; + updated_at: string; +} + +export interface PaymentMethodListResponse { + items: PaymentMethod[]; + has_more: boolean; + limit: number; + offset: number; +} diff --git a/packages/ts/src/models/search.ts b/packages/ts/src/models/search.ts new file mode 100644 index 0000000..e856830 --- /dev/null +++ b/packages/ts/src/models/search.ts @@ -0,0 +1,10 @@ +/** + * SearchResult — GET /v2/transactions/search response shape. + */ + +import type { Transaction } from './transaction.js'; + +export interface SearchResult { + transactions: Transaction[]; + total: number; +} diff --git a/packages/ts/src/models/status.ts b/packages/ts/src/models/status.ts new file mode 100644 index 0000000..1c5184c --- /dev/null +++ b/packages/ts/src/models/status.ts @@ -0,0 +1,20 @@ +/** + * /status and /whoami response shapes. + */ + +export interface StatusResponse { + status: 'ok' | string; + authenticated: boolean; +} + +export type WhoamiClientType = 'workspace' | 'user'; + +export interface WhoamiClient { + id: string; + name: string; + type: WhoamiClientType; +} + +export interface WhoamiResponse { + client: WhoamiClient; +} diff --git a/packages/ts/src/models/sync_session.ts b/packages/ts/src/models/sync_session.ts new file mode 100644 index 0000000..bb43525 --- /dev/null +++ b/packages/ts/src/models/sync_session.ts @@ -0,0 +1,44 @@ +/** + * SyncSession — record of a single bank-sync attempt. + * From GET /v2/accounts/{id}/sync_sessions and the POST /sync response. + */ + +export type SyncSessionStatus = 'pending' | 'started' | 'completed' | 'failed' | 'skipped'; + +export interface SyncSessionError { + type: string; + message: string; +} + +export interface SyncSessionPerformance { + total_duration: number; + complexity_score: number; + sync_speed_score: number; +} + +export interface SyncSession { + id: string; + status: SyncSessionStatus; + started_at: string; + completed_at: string | null; + transactions_synced: number; + accounts_count: number; + /** Only present when status === 'failed'. */ + error: SyncSessionError | null; + /** Optional metrics from the SpeedMetric association. */ + performance: SyncSessionPerformance | null; +} + +export interface SyncStartResponse { + message: string; + sync_session_id: string; + status: SyncSessionStatus; + started_at: string; +} + +export interface SyncSessionListResponse { + sync_sessions: SyncSession[]; + limit: number; + offset: number; + has_more: boolean; +} diff --git a/packages/ts/src/models/sync_transaction.ts b/packages/ts/src/models/sync_transaction.ts new file mode 100644 index 0000000..cdca7cd --- /dev/null +++ b/packages/ts/src/models/sync_transaction.ts @@ -0,0 +1,33 @@ +/** + * SyncTransaction — v2 sync response shape (Plaid-compatible flattened model). + * Returned in `added` / `modified` arrays by POST /v2/accounts/{id}/transactions/sync. + */ + +export interface SyncTransaction { + transaction_id: string; + account_id: string; + amount: number; + iso_currency_code: string; + unofficial_currency_code: string; + date: string; + datetime: string | null; + name: string; + merchant_name: string | null; + pending: boolean; + category: string[]; + /** Conditional: only when running balances are enabled. */ + running_balance_cents?: number; +} + +export interface SyncRemoved { + transaction_id: string; + account_id: string; +} + +export interface SyncResult { + added: SyncTransaction[]; + modified: SyncTransaction[]; + removed: SyncRemoved[]; + next_cursor: string | null; + has_more: boolean; +} diff --git a/packages/ts/src/models/transaction.ts b/packages/ts/src/models/transaction.ts new file mode 100644 index 0000000..88f91df --- /dev/null +++ b/packages/ts/src/models/transaction.ts @@ -0,0 +1,53 @@ +/** + * Transaction model — v1 schema, also used by GET /v2/transactions/{id}. + * Wire shape uses snake_case; we preserve it on the model. + */ + +export interface TransactionCategory { + name: string; + external_category_code: string | null; + created_at: string; + updated_at: string; +} + +export interface TransactionCounterparty { + name: string; +} + +export interface TransactionData { + amount_cents: number; + currency: string; + description: string; + transaction_date: string; + created_at: string | null; + created_at_date: string | null; + note: string | null; + external_service_id: string | null; + /** Conditional: only present when running balances are enabled for the workspace. */ + running_balance_cents?: number; +} + +export type TransactionStatus = 'posted' | 'pending' | 'failed' | string; + +export interface Transaction { + id: string; + status: TransactionStatus; + data: TransactionData; + tesote_imported_at: string; + tesote_updated_at: string; + transaction_categories: TransactionCategory[]; + counterparty: TransactionCounterparty | null; +} + +export interface CursorPagination { + has_more: boolean; + per_page: number; + after_id: string | null; + before_id: string | null; +} + +export interface TransactionListResponse { + total: number; + transactions: Transaction[]; + pagination: CursorPagination; +} diff --git a/packages/ts/src/models/transaction_order.ts b/packages/ts/src/models/transaction_order.ts new file mode 100644 index 0000000..6ea353a --- /dev/null +++ b/packages/ts/src/models/transaction_order.ts @@ -0,0 +1,88 @@ +/** + * TransactionOrder — outbound payment lifecycle object. + * State machine: draft -> pending_approval -> approved -> processing -> completed|failed|cancelled. + */ + +export type TransactionOrderStatus = + | 'draft' + | 'pending_approval' + | 'approved' + | 'processing' + | 'completed' + | 'failed' + | 'cancelled'; + +export interface Beneficiary { + name: string; + bank_code?: string | null; + account_number?: string | null; + identification_type?: string | null; + identification_number?: string | null; +} + +export interface TransactionOrderSourceAccount { + id: string; + name: string; + payment_method_id: string; +} + +export interface TransactionOrderDestination { + payment_method_id: string; + counterparty_id: string; + counterparty_name: string; +} + +export interface TransactionOrderFee { + amount: number; + currency: string; +} + +export interface TransactionOrderTesoteTransaction { + id: string; + status: string; +} + +export interface TransactionOrderLatestAttempt { + id: string; + status: string; + attempt_number: number; + external_reference: string | null; + submitted_at: string | null; + completed_at: string | null; + error_code: string | null; + error_message: string | null; +} + +export interface TransactionOrder { + id: string; + status: TransactionOrderStatus; + amount: number; + currency: string; + description: string; + reference: string | null; + external_reference: string | null; + idempotency_key: string | null; + batch_id: string | null; + scheduled_for: string | null; + approved_at: string | null; + submitted_at: string | null; + completed_at: string | null; + failed_at: string | null; + cancelled_at: string | null; + source_account: TransactionOrderSourceAccount; + destination: TransactionOrderDestination; + /** Null when fee_cents is zero. */ + fee: TransactionOrderFee | null; + execution_strategy: string | null; + tesote_transaction: TransactionOrderTesoteTransaction | null; + latest_attempt: TransactionOrderLatestAttempt | null; + created_at: string; + updated_at: string; +} + +export interface TransactionOrderListResponse { + items: TransactionOrder[]; + has_more: boolean; + limit: number; + offset: number; +} diff --git a/packages/ts/src/transport.ts b/packages/ts/src/transport.ts index 2d7d888..e9d3a12 100644 --- a/packages/ts/src/transport.ts +++ b/packages/ts/src/transport.ts @@ -246,8 +246,14 @@ export class Transport { }); if (res.status >= 200 && res.status < 300) { - const data = (safeJsonParse(text) ?? null) as T; - await this.afterSuccess(args, text, res.headers.get('content-type')); + const contentType = res.headers.get('content-type'); + const isJson = contentType?.includes('application/json') === true; + // why: non-JSON success bodies (CSV exports, file downloads) must come + // back as the raw text — JSON parsing them would silently coerce to null. + const data = ( + isJson ? (safeJsonParse(text) ?? null) : text.length > 0 ? text : null + ) as T; + await this.afterSuccess(args, text, contentType); return { status: res.status, headers: res.headers, data, requestId, rateLimit }; } diff --git a/packages/ts/src/transport_internals.ts b/packages/ts/src/transport_internals.ts index 300f962..a52a357 100644 --- a/packages/ts/src/transport_internals.ts +++ b/packages/ts/src/transport_internals.ts @@ -7,7 +7,7 @@ import { NetworkError, type TimeoutError, TlsError } from './errors.js'; import type { RateLimitSnapshot, RetryPolicy } from './transport_types.js'; -export const SDK_VERSION = '0.1.1'; +export const SDK_VERSION = '0.2.0'; export const DEFAULT_BASE_URL = 'https://equipo.tesote.com/api'; const DEFAULT_RETRY_STATUSES = new Set([429, 502, 503, 504]); @@ -125,11 +125,32 @@ const SDK_ERROR_NAMES = new Set([ 'WorkspaceSuspendedError', 'AccountDisabledError', 'HistorySyncForbiddenError', + 'NotFoundError', + 'AccountNotFoundError', + 'TransactionNotFoundError', + 'SyncSessionNotFoundError', + 'PaymentMethodNotFoundError', + 'TransactionOrderNotFoundError', + 'BatchNotFoundError', + 'BankConnectionNotFoundError', 'MutationDuringPaginationError', + 'InvalidOrderStateError', + 'SyncInProgressError', 'UnprocessableContentError', 'InvalidDateRangeError', + 'InvalidCursorError', + 'InvalidCountError', + 'InvalidLimitError', + 'InvalidQueryError', + 'MissingDateRangeError', + 'BankSubmissionError', + 'ValidationError', + 'BatchValidationError', 'RateLimitExceededError', + 'SyncRateLimitExceededError', 'ServiceUnavailableError', + 'BankUnderMaintenanceError', + 'InternalServerError', 'TransportError', 'NetworkError', 'TimeoutError', diff --git a/packages/ts/src/v1/accounts.ts b/packages/ts/src/v1/accounts.ts index 920f786..1308645 100644 --- a/packages/ts/src/v1/accounts.ts +++ b/packages/ts/src/v1/accounts.ts @@ -1,22 +1,13 @@ +import type { Account, AccountListResponse, PageBasedPagination } from '../models/account.js'; import type { Transport } from '../transport.js'; -export interface Account { - id: string; - name: string; - currency: string; - balance?: string | number; - [k: string]: unknown; -} +export type { Account, AccountListResponse, PageBasedPagination }; export interface AccountListParams { - cursor?: string; - limit?: number; -} - -export interface AccountListResponse { - data: Account[]; - next_cursor?: string | null; - [k: string]: unknown; + page?: number; + per_page?: number; + include?: string; + sort?: string; } export class V1AccountsClient { @@ -27,14 +18,30 @@ export class V1AccountsClient { method: 'GET', path: '/v1/accounts', query: { ...params }, + cache: { ttl: 60 }, }); return res.data; } + /** + * Async iterator over every page of /v1/accounts. Yields accounts one at a time. + */ + async *listAll(params: AccountListParams = {}): AsyncGenerator { + let page = params.page ?? 1; + const perPage = params.per_page ?? 50; + while (true) { + const res = await this.list({ ...params, page, per_page: perPage }); + for (const account of res.accounts) yield account; + if (page >= res.pagination.total_pages) return; + page += 1; + } + } + async get(id: string): Promise { const res = await this.transport.request({ method: 'GET', path: `/v1/accounts/${encodeURIComponent(id)}`, + cache: { ttl: 300 }, }); return res.data; } diff --git a/packages/ts/src/v1/index.ts b/packages/ts/src/v1/index.ts index 278e240..ae9c0fc 100644 --- a/packages/ts/src/v1/index.ts +++ b/packages/ts/src/v1/index.ts @@ -26,5 +26,14 @@ export class V1Client { export { V1AccountsClient } from './accounts.js'; export { V1TransactionsClient } from './transactions.js'; export { V1StatusClient } from './status.js'; -export type { Account, AccountListParams, AccountListResponse } from './accounts.js'; -export type { Transaction } from './transactions.js'; +export type { + Account, + AccountListParams, + AccountListResponse, + PageBasedPagination, +} from './accounts.js'; +export type { + Transaction, + TransactionListParams, + TransactionListResponse, +} from './transactions.js'; diff --git a/packages/ts/src/v1/status.ts b/packages/ts/src/v1/status.ts index 0b7217f..8c6dd6d 100644 --- a/packages/ts/src/v1/status.ts +++ b/packages/ts/src/v1/status.ts @@ -1,13 +1,24 @@ +import type { StatusResponse, WhoamiResponse } from '../models/status.js'; import type { Transport } from '../transport.js'; export class V1StatusClient { - constructor(private readonly _transport: Transport) {} + constructor(private readonly transport: Transport) {} - async status(): Promise { - throw new Error('not implemented'); + /** GET /status — auth not required. */ + async status(): Promise { + const res = await this.transport.request({ + method: 'GET', + path: '/status', + }); + return res.data; } - async whoami(): Promise { - throw new Error('not implemented'); + /** GET /whoami — auth required. */ + async whoami(): Promise { + const res = await this.transport.request({ + method: 'GET', + path: '/whoami', + }); + return res.data; } } diff --git a/packages/ts/src/v1/transactions.ts b/packages/ts/src/v1/transactions.ts index 0f04af1..ca96e5c 100644 --- a/packages/ts/src/v1/transactions.ts +++ b/packages/ts/src/v1/transactions.ts @@ -1,25 +1,59 @@ +import type { Transaction, TransactionListResponse } from '../models/transaction.js'; import type { Transport } from '../transport.js'; -export interface Transaction { - id: string; - account_id: string; - amount: string | number; - currency: string; - posted_at?: string; - [k: string]: unknown; +export type { Transaction, TransactionListResponse }; + +export interface TransactionListParams { + start_date?: string; + end_date?: string; + scope?: string; + page?: number; + per_page?: number; + transactions_after_id?: string; + transactions_before_id?: string; } export class V1TransactionsClient { - constructor(private readonly _transport: Transport) {} + constructor(private readonly transport: Transport) {} async listForAccount( - _accountId: string, - _params: { cursor?: string; limit?: number } = {}, - ): Promise { - throw new Error('not implemented'); + accountId: string, + params: TransactionListParams = {}, + ): Promise { + const res = await this.transport.request({ + method: 'GET', + path: `/v1/accounts/${encodeURIComponent(accountId)}/transactions`, + query: { ...params }, + }); + return res.data; + } + + /** + * Async iterator that follows cursor pagination across every page of + * /v1/accounts/{id}/transactions. Yields transactions one at a time. + */ + async *listAllForAccount( + accountId: string, + params: TransactionListParams = {}, + ): AsyncGenerator { + let after = params.transactions_after_id; + while (true) { + const page = await this.listForAccount(accountId, { + ...params, + ...(after !== undefined ? { transactions_after_id: after } : {}), + }); + for (const tx of page.transactions) yield tx; + if (!page.pagination.has_more || page.pagination.after_id === null) return; + after = page.pagination.after_id; + } } - async get(_id: string): Promise { - throw new Error('not implemented'); + async get(id: string): Promise { + const res = await this.transport.request({ + method: 'GET', + path: `/v1/transactions/${encodeURIComponent(id)}`, + cache: { ttl: 300 }, + }); + return res.data; } } diff --git a/packages/ts/src/v2/accounts.ts b/packages/ts/src/v2/accounts.ts index c277d61..49770e9 100644 --- a/packages/ts/src/v2/accounts.ts +++ b/packages/ts/src/v2/accounts.ts @@ -1,5 +1,7 @@ +import type { Account, AccountListResponse } from '../models/account.js'; +import type { SyncStartResponse } from '../models/sync_session.js'; import type { Transport } from '../transport.js'; -import type { Account, AccountListParams, AccountListResponse } from '../v1/accounts.js'; +import type { AccountListParams } from '../v1/accounts.js'; export class V2AccountsClient { constructor(private readonly transport: Transport) {} @@ -9,19 +11,45 @@ export class V2AccountsClient { method: 'GET', path: '/v2/accounts', query: { ...params }, + cache: { ttl: 60 }, }); return res.data; } + /** + * Async iterator over every page of /v2/accounts. Yields accounts one at a time. + */ + async *listAll(params: AccountListParams = {}): AsyncGenerator { + let page = params.page ?? 1; + const perPage = params.per_page ?? 50; + while (true) { + const res = await this.list({ ...params, page, per_page: perPage }); + for (const account of res.accounts) yield account; + if (page >= res.pagination.total_pages) return; + page += 1; + } + } + async get(id: string): Promise { const res = await this.transport.request({ method: 'GET', path: `/v2/accounts/${encodeURIComponent(id)}`, + cache: { ttl: 300 }, }); return res.data; } - async sync(_id: string, _opts: { idempotencyKey?: string } = {}): Promise { - throw new Error('not implemented'); + /** + * POST /v2/accounts/{id}/sync — fire a bank sync. Returns a SyncStartResponse; + * poll sync_sessions for completion. + */ + async sync(id: string, opts: { idempotencyKey?: string } = {}): Promise { + const res = await this.transport.request({ + method: 'POST', + path: `/v2/accounts/${encodeURIComponent(id)}/sync`, + body: {}, + ...(opts.idempotencyKey !== undefined ? { idempotencyKey: opts.idempotencyKey } : {}), + }); + return res.data; } } diff --git a/packages/ts/src/v2/batches.ts b/packages/ts/src/v2/batches.ts index 3e9790b..a4a8956 100644 --- a/packages/ts/src/v2/batches.ts +++ b/packages/ts/src/v2/batches.ts @@ -1,28 +1,98 @@ +import type { + BatchApproveResponse, + BatchCancelResponse, + BatchCreateResponse, + BatchSubmitResponse, + BatchSummary, +} from '../models/batch.js'; +import type { Beneficiary } from '../models/transaction_order.js'; import type { Transport } from '../transport.js'; +export interface BatchOrderInput { + destination_payment_method_id?: string | null; + beneficiary?: Beneficiary; + amount: string; + currency: string; + description: string; + scheduled_for?: string | null; + metadata?: Record; +} + +export interface BatchCreateInput { + orders: BatchOrderInput[]; +} + export class V2BatchesClient { - constructor(private readonly _transport: Transport) {} + constructor(private readonly transport: Transport) {} + /** POST /v2/accounts/{id}/batches */ async create( - _body: Record, - _opts: { idempotencyKey?: string } = {}, - ): Promise { - throw new Error('not implemented'); + accountId: string, + body: BatchCreateInput, + opts: { idempotencyKey?: string } = {}, + ): Promise { + const res = await this.transport.request({ + method: 'POST', + path: `/v2/accounts/${encodeURIComponent(accountId)}/batches`, + body, + ...(opts.idempotencyKey !== undefined ? { idempotencyKey: opts.idempotencyKey } : {}), + }); + return res.data; } - async get(_id: string): Promise { - throw new Error('not implemented'); + /** GET /v2/accounts/{id}/batches/{batch_id} */ + async get(accountId: string, batchId: string): Promise { + const res = await this.transport.request({ + method: 'GET', + path: `/v2/accounts/${encodeURIComponent(accountId)}/batches/${encodeURIComponent(batchId)}`, + }); + return res.data; } - async approve(_id: string, _opts: { idempotencyKey?: string } = {}): Promise { - throw new Error('not implemented'); + /** POST /v2/accounts/{id}/batches/{batch_id}/approve */ + async approve( + accountId: string, + batchId: string, + opts: { idempotencyKey?: string } = {}, + ): Promise { + const res = await this.transport.request({ + method: 'POST', + path: `/v2/accounts/${encodeURIComponent(accountId)}/batches/${encodeURIComponent(batchId)}/approve`, + body: {}, + ...(opts.idempotencyKey !== undefined ? { idempotencyKey: opts.idempotencyKey } : {}), + }); + return res.data; } - async submit(_id: string, _opts: { idempotencyKey?: string } = {}): Promise { - throw new Error('not implemented'); + /** POST /v2/accounts/{id}/batches/{batch_id}/submit */ + async submit( + accountId: string, + batchId: string, + opts: { token?: string | null; idempotencyKey?: string } = {}, + ): Promise { + const body: Record = {}; + if (opts.token !== undefined) body.token = opts.token; + const res = await this.transport.request({ + method: 'POST', + path: `/v2/accounts/${encodeURIComponent(accountId)}/batches/${encodeURIComponent(batchId)}/submit`, + body, + ...(opts.idempotencyKey !== undefined ? { idempotencyKey: opts.idempotencyKey } : {}), + }); + return res.data; } - async cancel(_id: string, _opts: { idempotencyKey?: string } = {}): Promise { - throw new Error('not implemented'); + /** POST /v2/accounts/{id}/batches/{batch_id}/cancel */ + async cancel( + accountId: string, + batchId: string, + opts: { idempotencyKey?: string } = {}, + ): Promise { + const res = await this.transport.request({ + method: 'POST', + path: `/v2/accounts/${encodeURIComponent(accountId)}/batches/${encodeURIComponent(batchId)}/cancel`, + body: {}, + ...(opts.idempotencyKey !== undefined ? { idempotencyKey: opts.idempotencyKey } : {}), + }); + return res.data; } } diff --git a/packages/ts/src/v2/index.ts b/packages/ts/src/v2/index.ts index 043af00..113b168 100644 --- a/packages/ts/src/v2/index.ts +++ b/packages/ts/src/v2/index.ts @@ -42,3 +42,24 @@ export { V2TransactionOrdersClient } from './transaction_orders.js'; export { V2BatchesClient } from './batches.js'; export { V2PaymentMethodsClient } from './payment_methods.js'; export { V2StatusClient } from './status.js'; + +export type { + V2TransactionListParams, + V2TransactionExportParams, + ExportResponse, + SyncRequestOptions, + BulkRequest, + SearchParams, +} from './transactions.js'; +export type { SyncSessionListParams } from './sync_sessions.js'; +export type { + TransactionOrderListParams, + TransactionOrderCreateInput, + TransactionOrderSubmitOptions, +} from './transaction_orders.js'; +export type { BatchOrderInput, BatchCreateInput } from './batches.js'; +export type { + PaymentMethodListParams, + PaymentMethodCreateInput, + PaymentMethodUpdateInput, +} from './payment_methods.js'; diff --git a/packages/ts/src/v2/payment_methods.ts b/packages/ts/src/v2/payment_methods.ts index e7fb9d9..984aaf6 100644 --- a/packages/ts/src/v2/payment_methods.ts +++ b/packages/ts/src/v2/payment_methods.ts @@ -1,32 +1,114 @@ +import type { + PaymentMethod, + PaymentMethodDetails, + PaymentMethodListResponse, + PaymentMethodType, +} from '../models/payment_method.js'; import type { Transport } from '../transport.js'; +export interface PaymentMethodListParams { + limit?: number; + offset?: number; + method_type?: PaymentMethodType; + currency?: string; + counterparty_id?: string; + /** Stringified boolean — the API takes "true" / "false" strings. */ + verified?: boolean; +} + +export interface PaymentMethodCreateInput { + method_type: PaymentMethodType; + currency: string; + label?: string | null; + counterparty_id?: string | null; + counterparty?: { name: string }; + details: PaymentMethodDetails; +} + +export interface PaymentMethodUpdateInput { + method_type?: PaymentMethodType; + currency?: string; + label?: string | null; + counterparty_id?: string | null; + counterparty?: { name: string }; + details?: Partial; +} + export class V2PaymentMethodsClient { - constructor(private readonly _transport: Transport) {} + constructor(private readonly transport: Transport) {} + + /** GET /v2/payment_methods */ + async list(params: PaymentMethodListParams = {}): Promise { + const query: Record = {}; + if (params.limit !== undefined) query.limit = params.limit; + if (params.offset !== undefined) query.offset = params.offset; + if (params.method_type !== undefined) query.method_type = params.method_type; + if (params.currency !== undefined) query.currency = params.currency; + if (params.counterparty_id !== undefined) query.counterparty_id = params.counterparty_id; + if (params.verified !== undefined) query.verified = params.verified ? 'true' : 'false'; + const res = await this.transport.request({ + method: 'GET', + path: '/v2/payment_methods', + query, + }); + return res.data; + } - async list(_params: Record = {}): Promise { - throw new Error('not implemented'); + async *listAll(params: PaymentMethodListParams = {}): AsyncGenerator { + let offset = params.offset ?? 0; + const limit = params.limit ?? 50; + while (true) { + const page = await this.list({ ...params, limit, offset }); + for (const pm of page.items) yield pm; + if (!page.has_more) return; + offset += page.items.length; + } } - async get(_id: string): Promise { - throw new Error('not implemented'); + /** GET /v2/payment_methods/{id} */ + async get(id: string): Promise { + const res = await this.transport.request({ + method: 'GET', + path: `/v2/payment_methods/${encodeURIComponent(id)}`, + }); + return res.data; } + /** POST /v2/payment_methods */ async create( - _body: Record, - _opts: { idempotencyKey?: string } = {}, - ): Promise { - throw new Error('not implemented'); + body: PaymentMethodCreateInput, + opts: { idempotencyKey?: string } = {}, + ): Promise { + const res = await this.transport.request({ + method: 'POST', + path: '/v2/payment_methods', + body: { payment_method: body }, + ...(opts.idempotencyKey !== undefined ? { idempotencyKey: opts.idempotencyKey } : {}), + }); + return res.data; } + /** PATCH /v2/payment_methods/{id} */ async update( - _id: string, - _body: Record, - _opts: { idempotencyKey?: string } = {}, - ): Promise { - throw new Error('not implemented'); + id: string, + body: PaymentMethodUpdateInput, + opts: { idempotencyKey?: string } = {}, + ): Promise { + const res = await this.transport.request({ + method: 'PATCH', + path: `/v2/payment_methods/${encodeURIComponent(id)}`, + body: { payment_method: body }, + ...(opts.idempotencyKey !== undefined ? { idempotencyKey: opts.idempotencyKey } : {}), + }); + return res.data; } - async delete(_id: string, _opts: { idempotencyKey?: string } = {}): Promise { - throw new Error('not implemented'); + /** DELETE /v2/payment_methods/{id} — returns 204; resolves to void. */ + async delete(id: string, opts: { idempotencyKey?: string } = {}): Promise { + await this.transport.request({ + method: 'DELETE', + path: `/v2/payment_methods/${encodeURIComponent(id)}`, + ...(opts.idempotencyKey !== undefined ? { idempotencyKey: opts.idempotencyKey } : {}), + }); } } diff --git a/packages/ts/src/v2/status.ts b/packages/ts/src/v2/status.ts index 6abce11..0dfabc5 100644 --- a/packages/ts/src/v2/status.ts +++ b/packages/ts/src/v2/status.ts @@ -1,13 +1,24 @@ +import type { StatusResponse, WhoamiResponse } from '../models/status.js'; import type { Transport } from '../transport.js'; export class V2StatusClient { - constructor(private readonly _transport: Transport) {} + constructor(private readonly transport: Transport) {} - async status(): Promise { - throw new Error('not implemented'); + /** GET /v2/status — auth not required. */ + async status(): Promise { + const res = await this.transport.request({ + method: 'GET', + path: '/v2/status', + }); + return res.data; } - async whoami(): Promise { - throw new Error('not implemented'); + /** GET /v2/whoami — auth required. */ + async whoami(): Promise { + const res = await this.transport.request({ + method: 'GET', + path: '/v2/whoami', + }); + return res.data; } } diff --git a/packages/ts/src/v2/sync_sessions.ts b/packages/ts/src/v2/sync_sessions.ts index 0e25b03..ad78361 100644 --- a/packages/ts/src/v2/sync_sessions.ts +++ b/packages/ts/src/v2/sync_sessions.ts @@ -1,13 +1,53 @@ +import type { + SyncSession, + SyncSessionListResponse, + SyncSessionStatus, +} from '../models/sync_session.js'; import type { Transport } from '../transport.js'; +export interface SyncSessionListParams { + limit?: number; + offset?: number; + status?: SyncSessionStatus; +} + export class V2SyncSessionsClient { - constructor(private readonly _transport: Transport) {} + constructor(private readonly transport: Transport) {} + + /** GET /v2/accounts/{id}/sync_sessions */ + async list( + accountId: string, + params: SyncSessionListParams = {}, + ): Promise { + const res = await this.transport.request({ + method: 'GET', + path: `/v2/accounts/${encodeURIComponent(accountId)}/sync_sessions`, + query: { ...params }, + }); + return res.data; + } - async list(_accountId: string, _params: Record = {}): Promise { - throw new Error('not implemented'); + /** Async iterator paging through every sync session for an account. */ + async *listAll( + accountId: string, + params: SyncSessionListParams = {}, + ): AsyncGenerator { + let offset = params.offset ?? 0; + const limit = params.limit ?? 50; + while (true) { + const page = await this.list(accountId, { ...params, limit, offset }); + for (const s of page.sync_sessions) yield s; + if (!page.has_more) return; + offset += page.sync_sessions.length; + } } - async get(_accountId: string, _sessionId: string): Promise { - throw new Error('not implemented'); + /** GET /v2/accounts/{id}/sync_sessions/{session_id} */ + async get(accountId: string, sessionId: string): Promise { + const res = await this.transport.request({ + method: 'GET', + path: `/v2/accounts/${encodeURIComponent(accountId)}/sync_sessions/${encodeURIComponent(sessionId)}`, + }); + return res.data; } } diff --git a/packages/ts/src/v2/transaction_orders.ts b/packages/ts/src/v2/transaction_orders.ts index 3aad97a..6fd330c 100644 --- a/packages/ts/src/v2/transaction_orders.ts +++ b/packages/ts/src/v2/transaction_orders.ts @@ -1,37 +1,121 @@ +import type { + Beneficiary, + TransactionOrder, + TransactionOrderListResponse, + TransactionOrderStatus, +} from '../models/transaction_order.js'; import type { Transport } from '../transport.js'; +export interface TransactionOrderListParams { + limit?: number; + offset?: number; + status?: TransactionOrderStatus; + created_after?: string; + created_before?: string; + batch_id?: string; +} + +export interface TransactionOrderCreateInput { + destination_payment_method_id?: string | null; + beneficiary?: Beneficiary; + amount: string; + currency: string; + description: string; + scheduled_for?: string | null; + idempotency_key?: string | null; + metadata?: Record; +} + +export interface TransactionOrderSubmitOptions { + /** Token (e.g. MFA/OTP) some banks require. */ + token?: string | null; + /** Idempotency key for the submit request itself. */ + idempotencyKey?: string; +} + export class V2TransactionOrdersClient { - constructor(private readonly _transport: Transport) {} + constructor(private readonly transport: Transport) {} + + /** GET /v2/accounts/{id}/transaction_orders */ + async list( + accountId: string, + params: TransactionOrderListParams = {}, + ): Promise { + const res = await this.transport.request({ + method: 'GET', + path: `/v2/accounts/${encodeURIComponent(accountId)}/transaction_orders`, + query: { ...params }, + }); + return res.data; + } - async list(_accountId: string, _params: Record = {}): Promise { - throw new Error('not implemented'); + async *listAll( + accountId: string, + params: TransactionOrderListParams = {}, + ): AsyncGenerator { + let offset = params.offset ?? 0; + const limit = params.limit ?? 50; + while (true) { + const page = await this.list(accountId, { ...params, limit, offset }); + for (const order of page.items) yield order; + if (!page.has_more) return; + offset += page.items.length; + } } - async get(_accountId: string, _orderId: string): Promise { - throw new Error('not implemented'); + /** GET /v2/accounts/{id}/transaction_orders/{order_id} */ + async get(accountId: string, orderId: string): Promise { + const res = await this.transport.request({ + method: 'GET', + path: `/v2/accounts/${encodeURIComponent(accountId)}/transaction_orders/${encodeURIComponent(orderId)}`, + }); + return res.data; } + /** POST /v2/accounts/{id}/transaction_orders */ async create( - _accountId: string, - _body: Record, - _opts: { idempotencyKey?: string } = {}, - ): Promise { - throw new Error('not implemented'); + accountId: string, + body: TransactionOrderCreateInput, + opts: { idempotencyKey?: string } = {}, + ): Promise { + const res = await this.transport.request({ + method: 'POST', + path: `/v2/accounts/${encodeURIComponent(accountId)}/transaction_orders`, + body: { transaction_order: body }, + ...(opts.idempotencyKey !== undefined ? { idempotencyKey: opts.idempotencyKey } : {}), + }); + return res.data; } + /** POST /v2/accounts/{id}/transaction_orders/{order_id}/submit */ async submit( - _accountId: string, - _orderId: string, - _opts: { idempotencyKey?: string } = {}, - ): Promise { - throw new Error('not implemented'); + accountId: string, + orderId: string, + opts: TransactionOrderSubmitOptions = {}, + ): Promise { + const body: Record = {}; + if (opts.token !== undefined) body.token = opts.token; + const res = await this.transport.request({ + method: 'POST', + path: `/v2/accounts/${encodeURIComponent(accountId)}/transaction_orders/${encodeURIComponent(orderId)}/submit`, + body, + ...(opts.idempotencyKey !== undefined ? { idempotencyKey: opts.idempotencyKey } : {}), + }); + return res.data; } + /** POST /v2/accounts/{id}/transaction_orders/{order_id}/cancel */ async cancel( - _accountId: string, - _orderId: string, - _opts: { idempotencyKey?: string } = {}, - ): Promise { - throw new Error('not implemented'); + accountId: string, + orderId: string, + opts: { idempotencyKey?: string } = {}, + ): Promise { + const res = await this.transport.request({ + method: 'POST', + path: `/v2/accounts/${encodeURIComponent(accountId)}/transaction_orders/${encodeURIComponent(orderId)}/cancel`, + body: {}, + ...(opts.idempotencyKey !== undefined ? { idempotencyKey: opts.idempotencyKey } : {}), + }); + return res.data; } } diff --git a/packages/ts/src/v2/transactions.ts b/packages/ts/src/v2/transactions.ts index 51e28dd..caf0f10 100644 --- a/packages/ts/src/v2/transactions.ts +++ b/packages/ts/src/v2/transactions.ts @@ -1,35 +1,203 @@ +import type { BulkResponse } from '../models/bulk.js'; +import type { SearchResult } from '../models/search.js'; +import type { SyncResult } from '../models/sync_transaction.js'; +import type { + CursorPagination, + Transaction, + TransactionListResponse, +} from '../models/transaction.js'; import type { Transport } from '../transport.js'; +export interface V2TransactionListParams { + start_date?: string; + end_date?: string; + scope?: string; + page?: number; + per_page?: number; + transactions_after_id?: string; + transactions_before_id?: string; + transaction_date_after?: string; + transaction_date_before?: string; + created_after?: string; + updated_after?: string; + amount_min?: number; + amount_max?: number; + amount?: number; + status?: string; + category_id?: string; + counterparty_id?: string; + q?: string; + type?: string; + reference_code?: string; +} + +export interface V2TransactionExportParams extends V2TransactionListParams { + format?: 'csv' | 'json'; +} + +export interface ExportResponse { + /** CSV body or pretty-printed JSON body, depending on `format`. */ + body: string; + contentType: string | null; + /** Suggested filename parsed from Content-Disposition (best-effort). */ + filename: string | null; +} + +export interface SyncRequestOptions { + count?: number; + cursor?: string | 'now' | null; + options?: { + include_running_balance?: boolean; + }; +} + +export interface BulkRequest { + account_ids: string[]; + page?: number; + per_page?: number; + limit?: number; + offset?: number; +} + +export interface SearchParams extends V2TransactionListParams { + q: string; + account_id?: string; + limit?: number; + offset?: number; +} + +const DISPOSITION_FILENAME = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i; + +function parseFilename(disposition: string | null): string | null { + if (disposition === null) return null; + const match = DISPOSITION_FILENAME.exec(disposition); + if (match === null) return null; + const raw = match[1]; + if (raw === undefined) return null; + try { + return decodeURIComponent(raw); + } catch { + return raw; + } +} + export class V2TransactionsClient { - constructor(private readonly _transport: Transport) {} + constructor(private readonly transport: Transport) {} + /** GET /v2/accounts/{id}/transactions */ async listForAccount( - _accountId: string, - _params: Record = {}, - ): Promise { - throw new Error('not implemented'); + accountId: string, + params: V2TransactionListParams = {}, + ): Promise { + const res = await this.transport.request({ + method: 'GET', + path: `/v2/accounts/${encodeURIComponent(accountId)}/transactions`, + query: { ...params }, + cache: { ttl: 60 }, + }); + return res.data; + } + + /** Cursor-following async iterator over /v2/accounts/{id}/transactions. */ + async *listAllForAccount( + accountId: string, + params: V2TransactionListParams = {}, + ): AsyncGenerator { + let after = params.transactions_after_id; + while (true) { + const page = await this.listForAccount(accountId, { + ...params, + ...(after !== undefined ? { transactions_after_id: after } : {}), + }); + for (const tx of page.transactions) yield tx; + const pg: CursorPagination = page.pagination; + if (!pg.has_more || pg.after_id === null) return; + after = pg.after_id; + } + } + + /** GET /v2/transactions/{id} */ + async get(id: string): Promise { + const res = await this.transport.request({ + method: 'GET', + path: `/v2/transactions/${encodeURIComponent(id)}`, + cache: { ttl: 300 }, + }); + return res.data; } - async get(_id: string): Promise { - throw new Error('not implemented'); + /** + * GET /v2/accounts/{id}/transactions/export — CSV or JSON file download. + * The body is returned as a string; up to 10,000 transactions per call. + */ + async export(accountId: string, params: V2TransactionExportParams = {}): Promise { + const res = await this.transport.request({ + method: 'GET', + path: `/v2/accounts/${encodeURIComponent(accountId)}/transactions/export`, + query: { ...params }, + headers: { Accept: '*/*' }, + }); + const contentType = res.headers.get('content-type'); + const body = typeof res.data === 'string' ? res.data : JSON.stringify(res.data); + return { + body, + contentType, + filename: parseFilename(res.headers.get('content-disposition')), + }; } - async export(_params: Record): Promise { - throw new Error('not implemented'); + /** + * POST /v2/accounts/{id}/transactions/sync — Plaid-style flattened sync. + */ + async sync( + accountId: string, + body: SyncRequestOptions = {}, + opts: { idempotencyKey?: string } = {}, + ): Promise { + const res = await this.transport.request({ + method: 'POST', + path: `/v2/accounts/${encodeURIComponent(accountId)}/transactions/sync`, + body: { ...body }, + ...(opts.idempotencyKey !== undefined ? { idempotencyKey: opts.idempotencyKey } : {}), + }); + return res.data; } - async sync(_params: Record): Promise { - throw new Error('not implemented'); + /** + * POST /v2/transactions/sync (legacy non-nested route). Identical request/response + * to {@link sync} but takes account context in the body rather than the path. + */ + async syncLegacy( + body: SyncRequestOptions & { account_id?: string } = {}, + opts: { idempotencyKey?: string } = {}, + ): Promise { + const res = await this.transport.request({ + method: 'POST', + path: '/v2/transactions/sync', + body: { ...body }, + ...(opts.idempotencyKey !== undefined ? { idempotencyKey: opts.idempotencyKey } : {}), + }); + return res.data; } - async bulk( - _items: ReadonlyArray, - _opts: { idempotencyKey?: string } = {}, - ): Promise { - throw new Error('not implemented'); + /** POST /v2/transactions/bulk */ + async bulk(body: BulkRequest, opts: { idempotencyKey?: string } = {}): Promise { + const res = await this.transport.request({ + method: 'POST', + path: '/v2/transactions/bulk', + body, + ...(opts.idempotencyKey !== undefined ? { idempotencyKey: opts.idempotencyKey } : {}), + }); + return res.data; } - async search(_params: Record): Promise { - throw new Error('not implemented'); + /** GET /v2/transactions/search */ + async search(params: SearchParams): Promise { + const res = await this.transport.request({ + method: 'GET', + path: '/v2/transactions/search', + query: { ...params }, + }); + return res.data; } } diff --git a/packages/ts/test/accounts.test.ts b/packages/ts/test/accounts.test.ts new file mode 100644 index 0000000..84e7434 --- /dev/null +++ b/packages/ts/test/accounts.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest'; +import { + AccountNotFoundError, + BankConnectionNotFoundError, + BankUnderMaintenanceError, + SyncInProgressError, + SyncRateLimitExceededError, + UnauthorizedError, +} from '../src/errors.js'; +import { V1Client, V2Client } from '../src/index.js'; +import { callAt, getBody, getHeader, getMethod, jsonResponse, makeFetchMock } from './helpers.js'; + +const accountFixture = (id: string) => ({ + id, + name: 'My Account', + data: { + masked_account_number: '****1234', + currency: 'VES', + transactions_data_current_as_of: null, + balance_data_current_as_of: null, + custom_user_provided_identifier: null, + }, + bank: { name: 'Test Bank' }, + legal_entity: { id: null, legal_name: null }, + tesote_created_at: '2026-01-01T00:00:00Z', + tesote_updated_at: '2026-01-01T00:00:00Z', +}); + +const pageEnvelope = ( + items: ReturnType[], + page: number, + totalPages: number, +) => ({ + total: items.length * totalPages, + accounts: items, + pagination: { + current_page: page, + per_page: items.length, + total_pages: totalPages, + total_count: items.length * totalPages, + }, +}); + +describe('V1 accounts', () => { + it('list passes query params', async () => { + const { fetch, calls } = makeFetchMock([ + jsonResponse(200, pageEnvelope([accountFixture('a1')], 1, 1)), + ]); + const c = new V1Client({ apiKey: 'k', fetch }); + const r = await c.accounts.list({ page: 2, per_page: 10 }); + expect(r.accounts).toHaveLength(1); + expect(calls[0]?.url).toContain('page=2'); + expect(calls[0]?.url).toContain('per_page=10'); + }); + + it('get hits /v1/accounts/{id}', async () => { + const { fetch, calls } = makeFetchMock([jsonResponse(200, accountFixture('a1'))]); + const c = new V1Client({ apiKey: 'k', fetch }); + const r = await c.accounts.get('a1'); + expect(r.id).toBe('a1'); + expect(calls[0]?.url).toContain('/v1/accounts/a1'); + }); + + it('get → 404 maps to AccountNotFoundError', async () => { + const { fetch } = makeFetchMock([ + jsonResponse(404, { error_code: 'ACCOUNT_NOT_FOUND', error: 'gone' }), + ]); + const c = new V1Client({ apiKey: 'k', fetch }); + await expect(c.accounts.get('a1')).rejects.toBeInstanceOf(AccountNotFoundError); + }); + + it('listAll iterates across all pages', async () => { + const { fetch, calls } = makeFetchMock([ + jsonResponse(200, pageEnvelope([accountFixture('a1'), accountFixture('a2')], 1, 2)), + jsonResponse(200, pageEnvelope([accountFixture('a3'), accountFixture('a4')], 2, 2)), + ]); + const c = new V1Client({ apiKey: 'k', fetch }); + const ids: string[] = []; + for await (const a of c.accounts.listAll({ per_page: 2 })) ids.push(a.id); + expect(ids).toEqual(['a1', 'a2', 'a3', 'a4']); + expect(calls).toHaveLength(2); + }); + + it('list → 401 maps to UnauthorizedError', async () => { + const { fetch } = makeFetchMock([jsonResponse(401, { error_code: 'UNAUTHORIZED' })]); + const c = new V1Client({ apiKey: 'k', fetch }); + await expect(c.accounts.list()).rejects.toBeInstanceOf(UnauthorizedError); + }); +}); + +describe('V2 accounts', () => { + it('list hits /v2/accounts', async () => { + const { fetch, calls } = makeFetchMock([ + jsonResponse(200, pageEnvelope([accountFixture('a1')], 1, 1)), + ]); + const c = new V2Client({ apiKey: 'k', fetch }); + await c.accounts.list(); + expect(calls[0]?.url).toContain('/v2/accounts'); + }); + + it('sync POSTs with idempotency key + body', async () => { + const { fetch, calls } = makeFetchMock([ + jsonResponse(202, { + message: 'Sync started', + sync_session_id: 'ss1', + status: 'pending', + started_at: '2026-04-28T19:21:00Z', + }), + ]); + const c = new V2Client({ apiKey: 'k', fetch }); + const r = await c.accounts.sync('a1', { idempotencyKey: 'IDEMP-1' }); + expect(r.sync_session_id).toBe('ss1'); + expect(getMethod(callAt(calls, 0))).toBe('POST'); + expect(calls[0]?.url).toContain('/v2/accounts/a1/sync'); + expect(getHeader(callAt(calls, 0), 'idempotency-key')).toBe('IDEMP-1'); + expect(getBody(callAt(calls, 0))).toEqual({}); + }); + + it('sync auto-generates idempotency key when none provided', async () => { + const { fetch, calls } = makeFetchMock([ + jsonResponse(202, { + message: 'Sync started', + sync_session_id: 'ss1', + status: 'pending', + started_at: 't', + }), + ]); + const c = new V2Client({ apiKey: 'k', fetch }); + await c.accounts.sync('a1'); + expect(getHeader(callAt(calls, 0), 'idempotency-key')).toMatch(/^[0-9a-f-]{36}$/); + }); + + it('sync 409 → SyncInProgressError', async () => { + const { fetch } = makeFetchMock([ + jsonResponse(409, { error_code: 'SYNC_IN_PROGRESS', error: 'busy' }), + ]); + const c = new V2Client({ apiKey: 'k', fetch }); + await expect(c.accounts.sync('a1')).rejects.toBeInstanceOf(SyncInProgressError); + }); + + it('sync 429 → SyncRateLimitExceededError', async () => { + const { fetch } = makeFetchMock([ + jsonResponse(429, { error_code: 'SYNC_RATE_LIMIT_EXCEEDED', retry_after: 30 }), + jsonResponse(429, { error_code: 'SYNC_RATE_LIMIT_EXCEEDED', retry_after: 30 }), + jsonResponse(429, { error_code: 'SYNC_RATE_LIMIT_EXCEEDED', retry_after: 30 }), + ]); + const c = new V2Client({ apiKey: 'k', fetch, retryPolicy: { maxAttempts: 1 } }); + await expect(c.accounts.sync('a1')).rejects.toBeInstanceOf(SyncRateLimitExceededError); + }); + + it('sync 503 → BankUnderMaintenanceError', async () => { + const { fetch } = makeFetchMock([jsonResponse(503, { error_code: 'BANK_UNDER_MAINTENANCE' })]); + const c = new V2Client({ apiKey: 'k', fetch, retryPolicy: { maxAttempts: 1 } }); + await expect(c.accounts.sync('a1')).rejects.toBeInstanceOf(BankUnderMaintenanceError); + }); + + it('sync 404 BANK_CONNECTION_NOT_FOUND', async () => { + const { fetch } = makeFetchMock([ + jsonResponse(404, { error_code: 'BANK_CONNECTION_NOT_FOUND' }), + ]); + const c = new V2Client({ apiKey: 'k', fetch }); + await expect(c.accounts.sync('a1')).rejects.toBeInstanceOf(BankConnectionNotFoundError); + }); +}); diff --git a/packages/ts/test/batches.test.ts b/packages/ts/test/batches.test.ts new file mode 100644 index 0000000..09b487a --- /dev/null +++ b/packages/ts/test/batches.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest'; +import { + AccountNotFoundError, + BatchNotFoundError, + BatchValidationError, + InvalidOrderStateError, +} from '../src/errors.js'; +import { V2Client } from '../src/index.js'; +import { callAt, getBody, getHeader, getMethod, jsonResponse, makeFetchMock } from './helpers.js'; + +const orderStub = (id: string) => ({ + id, + status: 'draft', + amount: 100, + currency: 'VES', + description: 'pay', + reference: null, + external_reference: null, + idempotency_key: null, + batch_id: 'b1', + scheduled_for: null, + approved_at: null, + submitted_at: null, + completed_at: null, + failed_at: null, + cancelled_at: null, + source_account: { id: 'a1', name: 'A1', payment_method_id: 'pm1' }, + destination: { payment_method_id: 'pm2', counterparty_id: 'c1', counterparty_name: 'X' }, + fee: null, + execution_strategy: null, + tesote_transaction: null, + latest_attempt: null, + created_at: 't', + updated_at: 't', +}); + +describe('V2 batches', () => { + it('create POSTs orders array', async () => { + const { fetch, calls } = makeFetchMock([ + jsonResponse(201, { batch_id: 'b1', orders: [orderStub('o1')], errors: [] }), + ]); + const c = new V2Client({ apiKey: 'k', fetch }); + await c.batches.create( + 'a1', + { + orders: [{ amount: '1', currency: 'VES', description: 'a', beneficiary: { name: 'X' } }], + }, + { idempotencyKey: 'B1' }, + ); + expect(getMethod(callAt(calls, 0))).toBe('POST'); + expect(calls[0]?.url).toContain('/v2/accounts/a1/batches'); + expect(getHeader(callAt(calls, 0), 'idempotency-key')).toBe('B1'); + const body = getBody(callAt(calls, 0)) as { orders: unknown[] }; + expect(body.orders).toHaveLength(1); + }); + + it('create → 400 BATCH_VALIDATION_ERROR', async () => { + const { fetch } = makeFetchMock([ + jsonResponse(400, { error_code: 'BATCH_VALIDATION_ERROR', error: 'bad batch' }), + ]); + const c = new V2Client({ apiKey: 'k', fetch }); + await expect(c.batches.create('a1', { orders: [] })).rejects.toBeInstanceOf( + BatchValidationError, + ); + }); + + it('get returns summary', async () => { + const { fetch, calls } = makeFetchMock([ + jsonResponse(200, { + batch_id: 'b1', + total_orders: 1, + total_amount_cents: 100, + amount_currency: 'VES', + statuses: { draft: 1 }, + batch_status: 'draft', + created_at: 't', + orders: [orderStub('o1')], + }), + ]); + const c = new V2Client({ apiKey: 'k', fetch }); + const r = await c.batches.get('a1', 'b1'); + expect(r.batch_status).toBe('draft'); + expect(calls[0]?.url).toContain('/v2/accounts/a1/batches/b1'); + }); + + it('get → 404 BATCH_NOT_FOUND', async () => { + const { fetch } = makeFetchMock([jsonResponse(404, { error_code: 'BATCH_NOT_FOUND' })]); + const c = new V2Client({ apiKey: 'k', fetch }); + await expect(c.batches.get('a1', 'b1')).rejects.toBeInstanceOf(BatchNotFoundError); + }); + + it('approve POSTs and returns counts', async () => { + const { fetch, calls } = makeFetchMock([jsonResponse(200, { approved: 5, failed: 0 })]); + const c = new V2Client({ apiKey: 'k', fetch }); + const r = await c.batches.approve('a1', 'b1'); + expect(r.approved).toBe(5); + expect(calls[0]?.url).toContain('/v2/accounts/a1/batches/b1/approve'); + expect(getMethod(callAt(calls, 0))).toBe('POST'); + expect(getHeader(callAt(calls, 0), 'idempotency-key')).toMatch(/^[0-9a-f-]{36}$/); + }); + + it('submit posts token', async () => { + const { fetch, calls } = makeFetchMock([jsonResponse(200, { enqueued: 3, failed: 0 })]); + const c = new V2Client({ apiKey: 'k', fetch }); + await c.batches.submit('a1', 'b1', { token: 'OTP' }); + expect(getBody(callAt(calls, 0))).toEqual({ token: 'OTP' }); + expect(calls[0]?.url).toContain('/v2/accounts/a1/batches/b1/submit'); + }); + + it('cancel returns shape', async () => { + const { fetch, calls } = makeFetchMock([ + jsonResponse(200, { cancelled: 2, skipped: 1, errors: [] }), + ]); + const c = new V2Client({ apiKey: 'k', fetch }); + const r = await c.batches.cancel('a1', 'b1'); + expect(r.cancelled).toBe(2); + expect(r.skipped).toBe(1); + expect(calls[0]?.url).toContain('/v2/accounts/a1/batches/b1/cancel'); + }); + + it('approve → 409 INVALID_ORDER_STATE', async () => { + const { fetch } = makeFetchMock([jsonResponse(409, { error_code: 'INVALID_ORDER_STATE' })]); + const c = new V2Client({ apiKey: 'k', fetch }); + await expect(c.batches.approve('a1', 'b1')).rejects.toBeInstanceOf(InvalidOrderStateError); + }); + + it('create → 404 ACCOUNT_NOT_FOUND', async () => { + const { fetch } = makeFetchMock([jsonResponse(404, { error_code: 'ACCOUNT_NOT_FOUND' })]); + const c = new V2Client({ apiKey: 'k', fetch }); + await expect(c.batches.create('zz', { orders: [] })).rejects.toBeInstanceOf( + AccountNotFoundError, + ); + }); +}); diff --git a/packages/ts/test/helpers.ts b/packages/ts/test/helpers.ts new file mode 100644 index 0000000..07ad5bf --- /dev/null +++ b/packages/ts/test/helpers.ts @@ -0,0 +1,76 @@ +import { expect, vi } from 'vitest'; + +export interface FetchCall { + url: string; + init: RequestInit; +} + +export function makeFetchMock( + responses: ReadonlyArray Response | Promise)>, +): { + fetch: typeof fetch; + calls: FetchCall[]; +} { + const calls: FetchCall[] = []; + let i = 0; + const fn = vi.fn(async (url: string | URL | Request, init: RequestInit = {}) => { + calls.push({ url: String(url), init }); + const next = responses[i++]; + if (next === undefined) throw new Error(`fetch called more times than mocked (${i})`); + return typeof next === 'function' ? await next() : next; + }) as unknown as typeof fetch; + return { fetch: fn, calls }; +} + +export function jsonResponse( + status: number, + body: unknown, + headers: Record = {}, +): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json', ...headers }, + }); +} + +export function rawResponse( + status: number, + body: string, + headers: Record, +): Response { + return new Response(body, { status, headers }); +} + +export function noContent(status = 204, headers: Record = {}): Response { + // why: Response constructor forbids a non-null body on 204/205/304. + return new Response(null, { status, headers }); +} + +/** Pull the i-th call, asserting it exists. Centralizes the runtime check so + * call sites stay free of non-null assertions (forbidden by biome). */ +export function callAt(calls: FetchCall[], idx: number): FetchCall { + const c = calls[idx]; + expect(c, `call #${idx} missing`).toBeDefined(); + return c as FetchCall; +} + +export function getHeader(call: FetchCall, name: string): string | null { + return new Headers(call.init.headers as HeadersInit).get(name); +} + +export function getMethod(call: FetchCall): string { + return (call.init.method ?? 'GET').toUpperCase(); +} + +export function getBody(call: FetchCall): unknown { + const body = call.init.body; + if (body === undefined || body === null) return undefined; + if (typeof body === 'string') { + try { + return JSON.parse(body) as unknown; + } catch { + return body; + } + } + return body; +} diff --git a/packages/ts/test/paymentMethods.test.ts b/packages/ts/test/paymentMethods.test.ts new file mode 100644 index 0000000..7434541 --- /dev/null +++ b/packages/ts/test/paymentMethods.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import { PaymentMethodNotFoundError, ValidationError } from '../src/errors.js'; +import { V2Client } from '../src/index.js'; +import { + callAt, + getBody, + getHeader, + getMethod, + jsonResponse, + makeFetchMock, + noContent, +} from './helpers.js'; + +const pm = (id: string) => ({ + id, + method_type: 'bank_account', + currency: 'VES', + label: null, + details: { bank_code: '0102', account_number: '****', holder_name: 'X' }, + verified: false, + verified_at: null, + last_used_at: null, + counterparty: { id: 'c1', name: 'C' }, + tesote_account: null, + created_at: 't', + updated_at: 't', +}); + +describe('V2 paymentMethods', () => { + it('list serializes verified=true to "true"', async () => { + const { fetch, calls } = makeFetchMock([ + jsonResponse(200, { items: [pm('p1')], has_more: false, limit: 50, offset: 0 }), + ]); + const c = new V2Client({ apiKey: 'k', fetch }); + await c.paymentMethods.list({ method_type: 'bank_account', verified: true }); + expect(calls[0]?.url).toContain('/v2/payment_methods'); + expect(calls[0]?.url).toContain('method_type=bank_account'); + expect(calls[0]?.url).toContain('verified=true'); + }); + + it('list verified=false serializes "false"', async () => { + const { fetch, calls } = makeFetchMock([ + jsonResponse(200, { items: [], has_more: false, limit: 50, offset: 0 }), + ]); + const c = new V2Client({ apiKey: 'k', fetch }); + await c.paymentMethods.list({ verified: false }); + expect(calls[0]?.url).toContain('verified=false'); + }); + + it('get → 404 PAYMENT_METHOD_NOT_FOUND', async () => { + const { fetch } = makeFetchMock([ + jsonResponse(404, { error_code: 'PAYMENT_METHOD_NOT_FOUND' }), + ]); + const c = new V2Client({ apiKey: 'k', fetch }); + await expect(c.paymentMethods.get('p1')).rejects.toBeInstanceOf(PaymentMethodNotFoundError); + }); + + it('create wraps body under payment_method', async () => { + const { fetch, calls } = makeFetchMock([jsonResponse(201, pm('p1'))]); + const c = new V2Client({ apiKey: 'k', fetch }); + await c.paymentMethods.create( + { + method_type: 'pago_movil', + currency: 'VES', + details: { phone_number: '+58...', identification_number: 'V12345' }, + }, + { idempotencyKey: 'PM1' }, + ); + expect(getMethod(callAt(calls, 0))).toBe('POST'); + expect(getHeader(callAt(calls, 0), 'idempotency-key')).toBe('PM1'); + const body = getBody(callAt(calls, 0)) as { payment_method: { method_type: string } }; + expect(body.payment_method.method_type).toBe('pago_movil'); + }); + + it('create → 400 VALIDATION_ERROR', async () => { + const { fetch } = makeFetchMock([ + jsonResponse(400, { error_code: 'VALIDATION_ERROR', error: 'bad input' }), + ]); + const c = new V2Client({ apiKey: 'k', fetch }); + await expect( + c.paymentMethods.create({ method_type: 'wire', currency: 'VES', details: {} }), + ).rejects.toBeInstanceOf(ValidationError); + }); + + it('update PATCHes', async () => { + const { fetch, calls } = makeFetchMock([jsonResponse(200, pm('p1'))]); + const c = new V2Client({ apiKey: 'k', fetch }); + await c.paymentMethods.update('p1', { label: 'New label' }, { idempotencyKey: 'U1' }); + expect(getMethod(callAt(calls, 0))).toBe('PATCH'); + expect(calls[0]?.url).toContain('/v2/payment_methods/p1'); + expect(getHeader(callAt(calls, 0), 'idempotency-key')).toBe('U1'); + const body = getBody(callAt(calls, 0)) as { payment_method: { label: string } }; + expect(body.payment_method.label).toBe('New label'); + }); + + it('delete returns void on 204', async () => { + const { fetch, calls } = makeFetchMock([noContent(204)]); + const c = new V2Client({ apiKey: 'k', fetch }); + await expect(c.paymentMethods.delete('p1')).resolves.toBeUndefined(); + expect(getMethod(callAt(calls, 0))).toBe('DELETE'); + expect(getHeader(callAt(calls, 0), 'idempotency-key')).toMatch(/^[0-9a-f-]{36}$/); + }); + + it('delete → 409 VALIDATION_ERROR (in use)', async () => { + const { fetch } = makeFetchMock([ + jsonResponse(409, { error_code: 'VALIDATION_ERROR', error: 'in use' }), + ]); + const c = new V2Client({ apiKey: 'k', fetch }); + await expect(c.paymentMethods.delete('p1')).rejects.toBeInstanceOf(ValidationError); + }); + + it('listAll iterates offset pages', async () => { + const { fetch } = makeFetchMock([ + jsonResponse(200, { items: [pm('p1'), pm('p2')], has_more: true, limit: 2, offset: 0 }), + jsonResponse(200, { items: [pm('p3')], has_more: false, limit: 2, offset: 2 }), + ]); + const c = new V2Client({ apiKey: 'k', fetch }); + const ids: string[] = []; + for await (const m of c.paymentMethods.listAll({ limit: 2 })) ids.push(m.id); + expect(ids).toEqual(['p1', 'p2', 'p3']); + }); +}); diff --git a/packages/ts/test/status.test.ts b/packages/ts/test/status.test.ts new file mode 100644 index 0000000..2fbe61e --- /dev/null +++ b/packages/ts/test/status.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { UnauthorizedError } from '../src/errors.js'; +import { V1Client, V2Client } from '../src/index.js'; +import { callAt, getMethod, jsonResponse, makeFetchMock } from './helpers.js'; + +describe('V1 status', () => { + it('GET /status — anonymous probe', async () => { + const { fetch, calls } = makeFetchMock([ + jsonResponse(200, { status: 'ok', authenticated: false }), + ]); + const c = new V1Client({ apiKey: 'k', fetch }); + const r = await c.status.status(); + expect(r).toEqual({ status: 'ok', authenticated: false }); + expect(getMethod(callAt(calls, 0))).toBe('GET'); + expect(calls[0]?.url).toContain('/status'); + }); + + it('GET /whoami — returns client envelope', async () => { + const { fetch } = makeFetchMock([ + jsonResponse(200, { client: { id: 'cid', name: 'acme', type: 'workspace' } }), + ]); + const c = new V1Client({ apiKey: 'k', fetch }); + const r = await c.status.whoami(); + expect(r.client.type).toBe('workspace'); + expect(r.client.id).toBe('cid'); + }); + + it('whoami → 401 maps to UnauthorizedError', async () => { + const { fetch } = makeFetchMock([ + jsonResponse(401, { error_code: 'UNAUTHORIZED', error: 'no' }), + ]); + const c = new V1Client({ apiKey: 'k', fetch }); + await expect(c.status.whoami()).rejects.toBeInstanceOf(UnauthorizedError); + }); +}); + +describe('V2 status', () => { + it('GET /v2/status', async () => { + const { fetch, calls } = makeFetchMock([ + jsonResponse(200, { status: 'ok', authenticated: false }), + ]); + const c = new V2Client({ apiKey: 'k', fetch }); + const r = await c.status.status(); + expect(r.status).toBe('ok'); + expect(calls[0]?.url).toContain('/v2/status'); + }); + + it('GET /v2/whoami', async () => { + const { fetch, calls } = makeFetchMock([ + jsonResponse(200, { client: { id: 'cid', name: 'a', type: 'user' } }), + ]); + const c = new V2Client({ apiKey: 'k', fetch }); + const r = await c.status.whoami(); + expect(r.client.type).toBe('user'); + expect(calls[0]?.url).toContain('/v2/whoami'); + }); +}); diff --git a/packages/ts/test/syncSessions.test.ts b/packages/ts/test/syncSessions.test.ts new file mode 100644 index 0000000..5f2f1c8 --- /dev/null +++ b/packages/ts/test/syncSessions.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import { + AccountNotFoundError, + BankConnectionNotFoundError, + SyncSessionNotFoundError, +} from '../src/errors.js'; +import { V2Client } from '../src/index.js'; +import { jsonResponse, makeFetchMock } from './helpers.js'; + +const session = (id: string, status: 'completed' | 'failed' | 'started' = 'completed') => ({ + id, + status, + started_at: '2026-04-28T12:00:00Z', + completed_at: status === 'completed' ? '2026-04-28T12:00:30Z' : null, + transactions_synced: 5, + accounts_count: 1, + error: status === 'failed' ? { type: 'NetError', message: 'down' } : null, + performance: null, +}); + +describe('V2 syncSessions', () => { + it('list builds query and yields sessions', async () => { + const { fetch, calls } = makeFetchMock([ + jsonResponse(200, { + sync_sessions: [session('s1'), session('s2')], + limit: 50, + offset: 0, + has_more: false, + }), + ]); + const c = new V2Client({ apiKey: 'k', fetch }); + const r = await c.syncSessions.list('a1', { status: 'completed' }); + expect(r.sync_sessions).toHaveLength(2); + expect(calls[0]?.url).toContain('/v2/accounts/a1/sync_sessions'); + expect(calls[0]?.url).toContain('status=completed'); + }); + + it('list → 404 ACCOUNT_NOT_FOUND', async () => { + const { fetch } = makeFetchMock([jsonResponse(404, { error_code: 'ACCOUNT_NOT_FOUND' })]); + const c = new V2Client({ apiKey: 'k', fetch }); + await expect(c.syncSessions.list('a1')).rejects.toBeInstanceOf(AccountNotFoundError); + }); + + it('list → 404 BANK_CONNECTION_NOT_FOUND', async () => { + const { fetch } = makeFetchMock([ + jsonResponse(404, { error_code: 'BANK_CONNECTION_NOT_FOUND' }), + ]); + const c = new V2Client({ apiKey: 'k', fetch }); + await expect(c.syncSessions.list('a1')).rejects.toBeInstanceOf(BankConnectionNotFoundError); + }); + + it('get returns session', async () => { + const { fetch, calls } = makeFetchMock([jsonResponse(200, session('s1'))]); + const c = new V2Client({ apiKey: 'k', fetch }); + const r = await c.syncSessions.get('a1', 's1'); + expect(r.id).toBe('s1'); + expect(calls[0]?.url).toContain('/v2/accounts/a1/sync_sessions/s1'); + }); + + it('get → 404 SYNC_SESSION_NOT_FOUND', async () => { + const { fetch } = makeFetchMock([jsonResponse(404, { error_code: 'SYNC_SESSION_NOT_FOUND' })]); + const c = new V2Client({ apiKey: 'k', fetch }); + await expect(c.syncSessions.get('a1', 's1')).rejects.toBeInstanceOf(SyncSessionNotFoundError); + }); + + it('listAll iterates offset pagination', async () => { + const { fetch, calls } = makeFetchMock([ + jsonResponse(200, { + sync_sessions: [session('s1'), session('s2')], + limit: 2, + offset: 0, + has_more: true, + }), + jsonResponse(200, { + sync_sessions: [session('s3')], + limit: 2, + offset: 2, + has_more: false, + }), + ]); + const c = new V2Client({ apiKey: 'k', fetch }); + const ids: string[] = []; + for await (const s of c.syncSessions.listAll('a1', { limit: 2 })) ids.push(s.id); + expect(ids).toEqual(['s1', 's2', 's3']); + expect(calls[1]?.url).toContain('offset=2'); + }); +}); diff --git a/packages/ts/test/transactionOrders.test.ts b/packages/ts/test/transactionOrders.test.ts new file mode 100644 index 0000000..03e6df1 --- /dev/null +++ b/packages/ts/test/transactionOrders.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest'; +import { + AccountNotFoundError, + BankSubmissionError, + InvalidOrderStateError, + TransactionOrderNotFoundError, + ValidationError, +} from '../src/errors.js'; +import { V2Client } from '../src/index.js'; +import { callAt, getBody, getHeader, getMethod, jsonResponse, makeFetchMock } from './helpers.js'; + +const order = (id: string, status: 'draft' | 'processing' | 'cancelled' = 'draft') => ({ + id, + status, + amount: 100, + currency: 'VES', + description: 'pay', + reference: null, + external_reference: null, + idempotency_key: null, + batch_id: null, + scheduled_for: null, + approved_at: null, + submitted_at: null, + completed_at: null, + failed_at: null, + cancelled_at: null, + source_account: { id: 'a1', name: 'A1', payment_method_id: 'pm1' }, + destination: { payment_method_id: 'pm2', counterparty_id: 'c1', counterparty_name: 'Bob' }, + fee: null, + execution_strategy: null, + tesote_transaction: null, + latest_attempt: null, + created_at: 't', + updated_at: 't', +}); + +describe('V2 transactionOrders', () => { + it('list serializes filters', async () => { + const { fetch, calls } = makeFetchMock([ + jsonResponse(200, { items: [order('o1')], has_more: false, limit: 50, offset: 0 }), + ]); + const c = new V2Client({ apiKey: 'k', fetch }); + await c.transactionOrders.list('a1', { status: 'draft', limit: 25 }); + expect(calls[0]?.url).toContain('/v2/accounts/a1/transaction_orders'); + expect(calls[0]?.url).toContain('status=draft'); + expect(calls[0]?.url).toContain('limit=25'); + }); + + it('get returns order', async () => { + const { fetch, calls } = makeFetchMock([jsonResponse(200, order('o1'))]); + const c = new V2Client({ apiKey: 'k', fetch }); + const r = await c.transactionOrders.get('a1', 'o1'); + expect(r.id).toBe('o1'); + expect(calls[0]?.url).toContain('/v2/accounts/a1/transaction_orders/o1'); + }); + + it('get → 404 TRANSACTION_ORDER_NOT_FOUND', async () => { + const { fetch } = makeFetchMock([ + jsonResponse(404, { error_code: 'TRANSACTION_ORDER_NOT_FOUND' }), + ]); + const c = new V2Client({ apiKey: 'k', fetch }); + await expect(c.transactionOrders.get('a1', 'o1')).rejects.toBeInstanceOf( + TransactionOrderNotFoundError, + ); + }); + + it('create wraps body under transaction_order key', async () => { + const { fetch, calls } = makeFetchMock([jsonResponse(201, order('o1'))]); + const c = new V2Client({ apiKey: 'k', fetch }); + await c.transactionOrders.create( + 'a1', + { + amount: '10.00', + currency: 'VES', + description: 'pay', + beneficiary: { name: 'Bob' }, + }, + { idempotencyKey: 'I1' }, + ); + expect(getMethod(callAt(calls, 0))).toBe('POST'); + expect(getBody(callAt(calls, 0))).toEqual({ + transaction_order: { + amount: '10.00', + currency: 'VES', + description: 'pay', + beneficiary: { name: 'Bob' }, + }, + }); + expect(getHeader(callAt(calls, 0), 'idempotency-key')).toBe('I1'); + expect(getHeader(callAt(calls, 0), 'content-type')).toBe('application/json'); + }); + + it('create → 400 VALIDATION_ERROR', async () => { + const { fetch } = makeFetchMock([ + jsonResponse(400, { error_code: 'VALIDATION_ERROR', error: 'amount required' }), + ]); + const c = new V2Client({ apiKey: 'k', fetch }); + await expect( + c.transactionOrders.create('a1', { amount: '', currency: 'VES', description: '' }), + ).rejects.toBeInstanceOf(ValidationError); + }); + + it('create → 404 ACCOUNT_NOT_FOUND', async () => { + const { fetch } = makeFetchMock([jsonResponse(404, { error_code: 'ACCOUNT_NOT_FOUND' })]); + const c = new V2Client({ apiKey: 'k', fetch }); + await expect( + c.transactionOrders.create('zz', { amount: '1', currency: 'VES', description: 'x' }), + ).rejects.toBeInstanceOf(AccountNotFoundError); + }); + + it('submit posts token body', async () => { + const { fetch, calls } = makeFetchMock([jsonResponse(202, order('o1', 'processing'))]); + const c = new V2Client({ apiKey: 'k', fetch }); + const r = await c.transactionOrders.submit('a1', 'o1', { token: 'OTP123' }); + expect(r.status).toBe('processing'); + expect(calls[0]?.url).toContain('/v2/accounts/a1/transaction_orders/o1/submit'); + expect(getBody(callAt(calls, 0))).toEqual({ token: 'OTP123' }); + expect(getHeader(callAt(calls, 0), 'idempotency-key')).toMatch(/^[0-9a-f-]{36}$/); + }); + + it('submit no token body is empty object', async () => { + const { fetch, calls } = makeFetchMock([jsonResponse(202, order('o1', 'processing'))]); + const c = new V2Client({ apiKey: 'k', fetch }); + await c.transactionOrders.submit('a1', 'o1'); + expect(getBody(callAt(calls, 0))).toEqual({}); + }); + + it('submit → 409 INVALID_ORDER_STATE', async () => { + const { fetch } = makeFetchMock([ + jsonResponse(409, { error_code: 'INVALID_ORDER_STATE', error: 'wrong state' }), + ]); + const c = new V2Client({ apiKey: 'k', fetch }); + await expect(c.transactionOrders.submit('a1', 'o1')).rejects.toBeInstanceOf( + InvalidOrderStateError, + ); + }); + + it('submit → 422 BANK_SUBMISSION_ERROR', async () => { + const { fetch } = makeFetchMock([ + jsonResponse(422, { error_code: 'BANK_SUBMISSION_ERROR', error: 'rejected' }), + ]); + const c = new V2Client({ apiKey: 'k', fetch }); + await expect(c.transactionOrders.submit('a1', 'o1')).rejects.toBeInstanceOf( + BankSubmissionError, + ); + }); + + it('cancel POSTs to /cancel', async () => { + const { fetch, calls } = makeFetchMock([jsonResponse(200, order('o1', 'cancelled'))]); + const c = new V2Client({ apiKey: 'k', fetch }); + const r = await c.transactionOrders.cancel('a1', 'o1', { idempotencyKey: 'C1' }); + expect(r.status).toBe('cancelled'); + expect(calls[0]?.url).toContain('/v2/accounts/a1/transaction_orders/o1/cancel'); + expect(getHeader(callAt(calls, 0), 'idempotency-key')).toBe('C1'); + }); + + it('listAll iterates offset pagination', async () => { + const { fetch } = makeFetchMock([ + jsonResponse(200, { + items: [order('o1'), order('o2')], + has_more: true, + limit: 2, + offset: 0, + }), + jsonResponse(200, { items: [order('o3')], has_more: false, limit: 2, offset: 2 }), + ]); + const c = new V2Client({ apiKey: 'k', fetch }); + const ids: string[] = []; + for await (const o of c.transactionOrders.listAll('a1', { limit: 2 })) ids.push(o.id); + expect(ids).toEqual(['o1', 'o2', 'o3']); + }); +}); diff --git a/packages/ts/test/transactions.test.ts b/packages/ts/test/transactions.test.ts new file mode 100644 index 0000000..5143cd2 --- /dev/null +++ b/packages/ts/test/transactions.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, it } from 'vitest'; +import { + AccountNotFoundError, + HistorySyncForbiddenError, + InvalidCountError, + InvalidCursorError, + InvalidDateRangeError, + TransactionNotFoundError, + UnprocessableContentError, +} from '../src/errors.js'; +import { V1Client, V2Client } from '../src/index.js'; +import { + callAt, + getBody, + getHeader, + getMethod, + jsonResponse, + makeFetchMock, + rawResponse, +} from './helpers.js'; + +const txFixture = (id: string) => ({ + id, + status: 'posted', + data: { + amount_cents: 1000, + currency: 'VES', + description: 'coffee', + transaction_date: '2026-04-28', + created_at: '2026-04-28T12:00:00Z', + created_at_date: '2026-04-28', + note: null, + external_service_id: null, + }, + tesote_imported_at: '2026-04-28T12:00:00Z', + tesote_updated_at: '2026-04-28T12:00:00Z', + transaction_categories: [], + counterparty: null, +}); + +const cursorPage = ( + ids: string[], + hasMore: boolean, + afterId: string | null, + beforeId: string | null, +) => ({ + total: ids.length, + transactions: ids.map(txFixture), + pagination: { has_more: hasMore, per_page: ids.length, after_id: afterId, before_id: beforeId }, +}); + +describe('V1 transactions', () => { + it('listForAccount serializes filters', async () => { + const { fetch, calls } = makeFetchMock([ + jsonResponse(200, cursorPage(['t1'], false, 't1', 't1')), + ]); + const c = new V1Client({ apiKey: 'k', fetch }); + await c.transactions.listForAccount('a1', { start_date: '2026-04-01', per_page: 10 }); + expect(calls[0]?.url).toContain('/v1/accounts/a1/transactions'); + expect(calls[0]?.url).toContain('start_date=2026-04-01'); + expect(calls[0]?.url).toContain('per_page=10'); + }); + + it('listForAccount → 422 INVALID_DATE_RANGE', async () => { + const { fetch } = makeFetchMock([ + jsonResponse(422, { error_code: 'INVALID_DATE_RANGE', error: 'bad' }), + ]); + const c = new V1Client({ apiKey: 'k', fetch }); + await expect(c.transactions.listForAccount('a1', { start_date: 'x' })).rejects.toBeInstanceOf( + InvalidDateRangeError, + ); + }); + + it('listAllForAccount follows after_id cursor', async () => { + const { fetch, calls } = makeFetchMock([ + jsonResponse(200, cursorPage(['t1', 't2'], true, 't2', 't1')), + jsonResponse(200, cursorPage(['t3'], false, 't3', 't3')), + ]); + const c = new V1Client({ apiKey: 'k', fetch }); + const ids: string[] = []; + for await (const t of c.transactions.listAllForAccount('a1')) ids.push(t.id); + expect(ids).toEqual(['t1', 't2', 't3']); + expect(calls[1]?.url).toContain('transactions_after_id=t2'); + }); + + it('get → 404 maps to TransactionNotFoundError', async () => { + const { fetch } = makeFetchMock([jsonResponse(404, { error_code: 'TRANSACTION_NOT_FOUND' })]); + const c = new V1Client({ apiKey: 'k', fetch }); + await expect(c.transactions.get('t1')).rejects.toBeInstanceOf(TransactionNotFoundError); + }); + + it('get returns Transaction', async () => { + const { fetch, calls } = makeFetchMock([jsonResponse(200, txFixture('t1'))]); + const c = new V1Client({ apiKey: 'k', fetch }); + const t = await c.transactions.get('t1'); + expect(t.id).toBe('t1'); + expect(calls[0]?.url).toContain('/v1/transactions/t1'); + }); +}); + +describe('V2 transactions', () => { + it('listForAccount hits /v2 path with filters', async () => { + const { fetch, calls } = makeFetchMock([ + jsonResponse(200, cursorPage(['t1'], false, 't1', 't1')), + ]); + const c = new V2Client({ apiKey: 'k', fetch }); + await c.transactions.listForAccount('a1', { q: 'coffee', amount_min: 100 }); + expect(calls[0]?.url).toContain('/v2/accounts/a1/transactions'); + expect(calls[0]?.url).toContain('q=coffee'); + expect(calls[0]?.url).toContain('amount_min=100'); + }); + + it('listForAccount → 404 ACCOUNT_NOT_FOUND', async () => { + const { fetch } = makeFetchMock([jsonResponse(404, { error_code: 'ACCOUNT_NOT_FOUND' })]); + const c = new V2Client({ apiKey: 'k', fetch }); + await expect(c.transactions.listForAccount('missing')).rejects.toBeInstanceOf( + AccountNotFoundError, + ); + }); + + it('get returns v1-shape transaction', async () => { + const { fetch, calls } = makeFetchMock([jsonResponse(200, txFixture('t9'))]); + const c = new V2Client({ apiKey: 'k', fetch }); + const t = await c.transactions.get('t9'); + expect(t.id).toBe('t9'); + expect(calls[0]?.url).toContain('/v2/transactions/t9'); + }); + + it('export returns raw CSV body and parses filename', async () => { + const csv = 'Transaction ID,Date\nt1,2026-04-28\n'; + const { fetch, calls } = makeFetchMock([ + rawResponse(200, csv, { + 'content-type': 'text/csv', + 'content-disposition': 'attachment; filename="transactions_a1_2026-04-28.csv"', + }), + ]); + const c = new V2Client({ apiKey: 'k', fetch }); + const r = await c.transactions.export('a1', { format: 'csv' }); + expect(r.body).toBe(csv); + expect(r.contentType).toBe('text/csv'); + expect(r.filename).toBe('transactions_a1_2026-04-28.csv'); + expect(calls[0]?.url).toContain('format=csv'); + }); + + it('sync POSTs to nested path with idempotency-key', async () => { + const { fetch, calls } = makeFetchMock([ + jsonResponse(200, { + added: [], + modified: [], + removed: [], + next_cursor: null, + has_more: false, + }), + ]); + const c = new V2Client({ apiKey: 'k', fetch }); + await c.transactions.sync('a1', { count: 50, cursor: 'now' }, { idempotencyKey: 'i1' }); + expect(getMethod(callAt(calls, 0))).toBe('POST'); + expect(calls[0]?.url).toContain('/v2/accounts/a1/transactions/sync'); + expect(getBody(callAt(calls, 0))).toEqual({ count: 50, cursor: 'now' }); + expect(getHeader(callAt(calls, 0), 'idempotency-key')).toBe('i1'); + expect(getHeader(callAt(calls, 0), 'content-type')).toBe('application/json'); + }); + + it('sync 422 INVALID_COUNT', async () => { + const { fetch } = makeFetchMock([jsonResponse(422, { error_code: 'INVALID_COUNT' })]); + const c = new V2Client({ apiKey: 'k', fetch }); + await expect(c.transactions.sync('a1', { count: 99999 })).rejects.toBeInstanceOf( + InvalidCountError, + ); + }); + + it('sync 422 INVALID_CURSOR', async () => { + const { fetch } = makeFetchMock([jsonResponse(422, { error_code: 'INVALID_CURSOR' })]); + const c = new V2Client({ apiKey: 'k', fetch }); + await expect(c.transactions.sync('a1', { cursor: 'garbage' })).rejects.toBeInstanceOf( + InvalidCursorError, + ); + }); + + it('sync 403 HISTORY_SYNC_FORBIDDEN', async () => { + const { fetch } = makeFetchMock([ + jsonResponse(403, { error_code: 'HISTORY_SYNC_FORBIDDEN', error: 'enable feature' }), + ]); + const c = new V2Client({ apiKey: 'k', fetch }); + await expect(c.transactions.sync('a1', { cursor: null })).rejects.toBeInstanceOf( + HistorySyncForbiddenError, + ); + }); + + it('syncLegacy hits /v2/transactions/sync', async () => { + const { fetch, calls } = makeFetchMock([ + jsonResponse(200, { + added: [], + modified: [], + removed: [], + next_cursor: null, + has_more: false, + }), + ]); + const c = new V2Client({ apiKey: 'k', fetch }); + await c.transactions.syncLegacy({ account_id: 'a1', count: 10 }); + expect(calls[0]?.url).toContain('/v2/transactions/sync'); + expect(getBody(callAt(calls, 0))).toEqual({ account_id: 'a1', count: 10 }); + }); + + it('bulk POSTs account_ids body', async () => { + const { fetch, calls } = makeFetchMock([jsonResponse(200, { bulk_results: [] })]); + const c = new V2Client({ apiKey: 'k', fetch }); + await c.transactions.bulk({ account_ids: ['a1', 'a2'], per_page: 25 }); + expect(getBody(callAt(calls, 0))).toEqual({ account_ids: ['a1', 'a2'], per_page: 25 }); + expect(calls[0]?.url).toContain('/v2/transactions/bulk'); + }); + + it('bulk → 422 UNPROCESSABLE_CONTENT', async () => { + const { fetch } = makeFetchMock([ + jsonResponse(422, { error_code: 'UNPROCESSABLE_CONTENT', error: 'too many' }), + ]); + const c = new V2Client({ apiKey: 'k', fetch }); + await expect(c.transactions.bulk({ account_ids: [] })).rejects.toBeInstanceOf( + UnprocessableContentError, + ); + }); + + it('search query builds URL', async () => { + const { fetch, calls } = makeFetchMock([jsonResponse(200, { transactions: [], total: 0 })]); + const c = new V2Client({ apiKey: 'k', fetch }); + await c.transactions.search({ q: 'starbucks', limit: 50 }); + expect(calls[0]?.url).toContain('/v2/transactions/search'); + expect(calls[0]?.url).toContain('q=starbucks'); + expect(calls[0]?.url).toContain('limit=50'); + }); + + it('listAllForAccount drives cursor pagination', async () => { + const { fetch } = makeFetchMock([ + jsonResponse(200, cursorPage(['t1', 't2'], true, 't2', 't1')), + jsonResponse(200, cursorPage(['t3'], false, 't3', 't3')), + ]); + const c = new V2Client({ apiKey: 'k', fetch }); + const ids: string[] = []; + for await (const t of c.transactions.listAllForAccount('a1')) ids.push(t.id); + expect(ids).toEqual(['t1', 't2', 't3']); + }); + + it('POST without server complains 415 — surfaced as UnprocessableContentError when error_code maps so', async () => { + const { fetch } = makeFetchMock([ + jsonResponse(415, { error_code: 'UNPROCESSABLE_CONTENT', error: 'need json' }), + ]); + const c = new V2Client({ apiKey: 'k', fetch }); + // why: 415 means missing Content-Type. Real SDK always sends it on bodies, but the + // server-side code is exercised here to confirm the mapping pass-through. + await expect(c.transactions.bulk({ account_ids: ['a1'] })).rejects.toBeInstanceOf( + UnprocessableContentError, + ); + }); +}); diff --git a/packages/ts/test/transport.test.ts b/packages/ts/test/transport.test.ts index 1bd28dd..5e02c6a 100644 --- a/packages/ts/test/transport.test.ts +++ b/packages/ts/test/transport.test.ts @@ -53,7 +53,7 @@ describe('Transport — bearer + headers', () => { const headers = new Headers(calls[0]?.init.headers as HeadersInit); expect(headers.get('authorization')).toBe('Bearer sk_test_abcd1234'); expect(headers.get('accept')).toBe('application/json'); - expect(headers.get('user-agent')).toMatch(/^@tesote\.com\/sdk-ts\/0\.1\.1 \(node\//); + expect(headers.get('user-agent')).toMatch(/^@tesote\.com\/sdk-ts\/0\.2\.0 \(node\//); }); it('hits the default base URL when none provided', async () => { From bd1f2d4bdb4ab21261ff5a4d8cc76bed4075b473 Mon Sep 17 00:00:00 2001 From: sebi Date: Tue, 28 Apr 2026 19:48:03 -0500 Subject: [PATCH 08/10] parity: enroll csharp + align method names with manifest - spec/parity.yaml: add csharp entry (pascal case, Async suffix, .cs extension) - bin/check_parity.py: support optional async_suffix per language so the parity grep matches WhoamiAsync/UpdateAsync/DeleteAsync etc. - csharp: move ListForAccount from V1.AccountsClient.ListTransactionsAsync to V1.TransactionsClient.ListForAccountAsync; rename V2.TransactionsClient.ListAsync -> ListForAccountAsync (matches the cross-language manifest spelling). Tests updated. dotnet build -c Release clean (0 warnings, TreatWarningsAsErrors), 70/70 tests pass, python3 bin/check_parity.py reports OK for all six checked languages. Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/check_parity.py | 3 ++ packages/csharp/src/V1/AccountsClient.cs | 31 +------------------ packages/csharp/src/V1/TransactionsClient.cs | 32 +++++++++++++++++++- packages/csharp/src/V2/TransactionsClient.cs | 2 +- packages/csharp/tests/TransactionsTests.cs | 4 +-- spec/parity.yaml | 1 + 6 files changed, 39 insertions(+), 34 deletions(-) diff --git a/bin/check_parity.py b/bin/check_parity.py index 4ae4026..b03e550 100755 --- a/bin/check_parity.py +++ b/bin/check_parity.py @@ -105,12 +105,15 @@ def check_language(lang_cfg: dict, manifest: dict) -> LanguageReport: source = collect_source(lang_dir, lang_cfg["extensions"]) case = lang_cfg["method_case"] suffix = lang_cfg["class_suffix"] + async_suffix = lang_cfg.get("async_suffix", "") # --- methods --- for version, vcfg in manifest["versions"].items(): for resource, rcfg in vcfg["resources"].items(): for method in rcfg["methods"]: spellings = method_variants(method, case) + if async_suffix: + spellings = spellings + [s + async_suffix for s in spellings] if not any(re.search(rf"\b{re.escape(s)}\b", source) for s in spellings): report.missing_methods.append(f"{version}.{resource}.{method}") diff --git a/packages/csharp/src/V1/AccountsClient.cs b/packages/csharp/src/V1/AccountsClient.cs index 3c31801..5f95358 100644 --- a/packages/csharp/src/V1/AccountsClient.cs +++ b/packages/csharp/src/V1/AccountsClient.cs @@ -6,7 +6,7 @@ namespace Tesote.Sdk.V1; -///

v1 Accounts resource: list, get, and per-account transaction listing. +/// v1 Accounts resource: list and get. public sealed class AccountsClient { private const string BasePath = "/v1/accounts"; @@ -49,33 +49,4 @@ public async Task GetAsync(string accountId, CancellationToken ct = def var node = await _transport.RequestAsync(opts, ct).ConfigureAwait(false); return Json.Deserialize(node); } - - /// List transactions for a single account, with optional date and cursor filters. - public async Task ListTransactionsAsync( - string accountId, - string? startDate = null, - string? endDate = null, - string? scope = null, - int? page = null, - int? perPage = null, - string? transactionsAfterId = null, - string? transactionsBeforeId = null, - CancellationToken ct = default) - { - ArgumentException.ThrowIfNullOrEmpty(accountId); - var query = new QueryBuilder() - .Add("start_date", startDate) - .Add("end_date", endDate) - .Add("scope", scope) - .Add("page", page) - .Add("per_page", perPage) - .Add("transactions_after_id", transactionsAfterId) - .Add("transactions_before_id", transactionsBeforeId) - .BuildOrNull(); - - var opts = RequestOptions.Get(BasePath + "/" + Uri.EscapeDataString(accountId) + "/transactions"); - opts.Query = query; - var node = await _transport.RequestAsync(opts, ct).ConfigureAwait(false); - return Json.Deserialize(node); - } } diff --git a/packages/csharp/src/V1/TransactionsClient.cs b/packages/csharp/src/V1/TransactionsClient.cs index 6c77730..7b258e5 100644 --- a/packages/csharp/src/V1/TransactionsClient.cs +++ b/packages/csharp/src/V1/TransactionsClient.cs @@ -6,10 +6,11 @@ namespace Tesote.Sdk.V1; -/// v1 Transactions resource. Lookup-by-id only; list lives on the account client. +/// v1 Transactions resource: per-account listing and single-id lookup. public sealed class TransactionsClient { private const string BasePath = "/v1/transactions"; + private const string AccountsBasePath = "/v1/accounts"; private readonly Transport _transport; @@ -18,6 +19,35 @@ internal TransactionsClient(Transport transport) _transport = transport; } + /// List transactions for a single account, with optional date and cursor filters. + public async Task ListForAccountAsync( + string accountId, + string? startDate = null, + string? endDate = null, + string? scope = null, + int? page = null, + int? perPage = null, + string? transactionsAfterId = null, + string? transactionsBeforeId = null, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrEmpty(accountId); + var query = new QueryBuilder() + .Add("start_date", startDate) + .Add("end_date", endDate) + .Add("scope", scope) + .Add("page", page) + .Add("per_page", perPage) + .Add("transactions_after_id", transactionsAfterId) + .Add("transactions_before_id", transactionsBeforeId) + .BuildOrNull(); + + var opts = RequestOptions.Get(AccountsBasePath + "/" + Uri.EscapeDataString(accountId) + "/transactions"); + opts.Query = query; + var node = await _transport.RequestAsync(opts, ct).ConfigureAwait(false); + return Json.Deserialize(node); + } + /// Fetch a single transaction by id. public async Task GetAsync(string transactionId, CancellationToken ct = default) { diff --git a/packages/csharp/src/V2/TransactionsClient.cs b/packages/csharp/src/V2/TransactionsClient.cs index 07df226..2f8918c 100644 --- a/packages/csharp/src/V2/TransactionsClient.cs +++ b/packages/csharp/src/V2/TransactionsClient.cs @@ -92,7 +92,7 @@ public sealed class ListFilters } /// List transactions for an account with the full v2 filter surface. - public async Task ListAsync( + public async Task ListForAccountAsync( string accountId, ListFilters? filters = null, CancellationToken ct = default) diff --git a/packages/csharp/tests/TransactionsTests.cs b/packages/csharp/tests/TransactionsTests.cs index a7e2621..8803778 100644 --- a/packages/csharp/tests/TransactionsTests.cs +++ b/packages/csharp/tests/TransactionsTests.cs @@ -44,7 +44,7 @@ public async Task V1ListTransactionsReturnsCursorPagination() "\"pagination\":{\"has_more\":false,\"per_page\":50,\"after_id\":\"tx_1\",\"before_id\":\"tx_1\"}}")); using var client = TestHelpers.NewV1(_server.Url + "/api"); - var result = await client.Accounts.ListTransactionsAsync("acc_1", perPage: 50); + var result = await client.Transactions.ListForAccountAsync("acc_1", perPage: 50); Assert.Equal(1, result.Total); Assert.Equal("tx_1", result.Transactions[0].Id); Assert.Equal(1000, result.Transactions[0].Data.AmountCents); @@ -61,7 +61,7 @@ public async Task V2ListMaps422ToInvalidDateRange() using var client = TestHelpers.NewV2(_server.Url + "/api"); var ex = await Assert.ThrowsAsync( - () => client.Transactions.ListAsync("acc_1")); + () => client.Transactions.ListForAccountAsync("acc_1")); Assert.IsAssignableFrom(ex); } diff --git a/spec/parity.yaml b/spec/parity.yaml index 6158321..debcfd5 100644 --- a/spec/parity.yaml +++ b/spec/parity.yaml @@ -59,3 +59,4 @@ languages: - { dir: java, method_case: camel, class_suffix: Exception, extensions: [java] } - { dir: php, method_case: camel, class_suffix: Exception, extensions: [php] } - { dir: go, method_case: pascal, class_suffix: Error, extensions: [go] } + - { dir: csharp, method_case: pascal, class_suffix: Exception, extensions: [cs], async_suffix: Async } From 55bfa57c3f16238181003cb05bc53499b5d76772 Mon Sep 17 00:00:00 2001 From: sebi Date: Tue, 28 Apr 2026 19:53:47 -0500 Subject: [PATCH 09/10] php: use property-level readonly so models parse on PHP 8.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md mandates a PHP 8.1 floor. The agents that scaffolded the v2 model classes used class-level `final readonly class X` syntax, which PHP 8.1 cannot parse — CI on PHP 8.1 was failing with linter errors on all 37 model files. Move `readonly` from the class declaration onto each promoted constructor parameter (`public readonly string $foo,`). Same immutability guarantee, syntax-compatible with 8.1+. composer cs-check / phpstan (level 8) / phpunit all clean (84 tests / 302 assertions). `php -l` against every modified file confirms no syntax errors. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/php/src/Models/Account.php | 16 +++---- packages/php/src/Models/AccountData.php | 16 +++---- packages/php/src/Models/AccountList.php | 8 ++-- packages/php/src/Models/Bank.php | 4 +- packages/php/src/Models/BatchActionResult.php | 14 +++--- packages/php/src/Models/BatchCreated.php | 8 ++-- packages/php/src/Models/BatchSummary.php | 18 +++---- packages/php/src/Models/BulkAccountResult.php | 8 ++-- packages/php/src/Models/BulkResult.php | 4 +- packages/php/src/Models/Counterparty.php | 6 +-- packages/php/src/Models/CursorPagination.php | 10 ++-- packages/php/src/Models/Destination.php | 8 ++-- packages/php/src/Models/ExportFile.php | 8 ++-- packages/php/src/Models/LegalEntity.php | 6 +-- packages/php/src/Models/Money.php | 6 +-- packages/php/src/Models/PagePagination.php | 10 ++-- packages/php/src/Models/PaymentMethod.php | 26 +++++----- packages/php/src/Models/PaymentMethodList.php | 10 ++-- packages/php/src/Models/SourceAccount.php | 8 ++-- packages/php/src/Models/StatusInfo.php | 6 +-- packages/php/src/Models/SyncRemoval.php | 6 +-- packages/php/src/Models/SyncResult.php | 12 ++--- packages/php/src/Models/SyncSession.php | 18 +++---- packages/php/src/Models/SyncSessionList.php | 10 ++-- packages/php/src/Models/SyncStarted.php | 10 ++-- packages/php/src/Models/SyncTransaction.php | 26 +++++----- packages/php/src/Models/TesoteAccountRef.php | 6 +-- .../php/src/Models/TesoteTransactionRef.php | 6 +-- packages/php/src/Models/Transaction.php | 16 +++---- .../php/src/Models/TransactionCategory.php | 10 ++-- packages/php/src/Models/TransactionData.php | 20 ++++---- packages/php/src/Models/TransactionList.php | 8 ++-- packages/php/src/Models/TransactionOrder.php | 48 +++++++++---------- .../src/Models/TransactionOrderAttempt.php | 18 +++---- .../php/src/Models/TransactionOrderList.php | 10 ++-- .../src/Models/TransactionSearchResult.php | 6 +-- packages/php/src/Models/WhoAmI.php | 8 ++-- 37 files changed, 219 insertions(+), 219 deletions(-) diff --git a/packages/php/src/Models/Account.php b/packages/php/src/Models/Account.php index 108359c..0073c79 100644 --- a/packages/php/src/Models/Account.php +++ b/packages/php/src/Models/Account.php @@ -5,16 +5,16 @@ namespace Tesote\Sdk\Models; /** Account model (v1 + v2 — identical wire shape). */ -final readonly class Account +final class Account { public function __construct( - public string $id, - public string $name, - public AccountData $data, - public Bank $bank, - public ?LegalEntity $legalEntity, - public string $tesoteCreatedAt, - public string $tesoteUpdatedAt, + public readonly string $id, + public readonly string $name, + public readonly AccountData $data, + public readonly Bank $bank, + public readonly ?LegalEntity $legalEntity, + public readonly string $tesoteCreatedAt, + public readonly string $tesoteUpdatedAt, ) { } diff --git a/packages/php/src/Models/AccountData.php b/packages/php/src/Models/AccountData.php index a0355be..344dee2 100644 --- a/packages/php/src/Models/AccountData.php +++ b/packages/php/src/Models/AccountData.php @@ -11,16 +11,16 @@ * balanceCents / availableBalanceCents only present when the workspace allows * balance display. Returned as strings on the wire (decimal-safe). */ -final readonly class AccountData +final class AccountData { public function __construct( - public ?string $maskedAccountNumber, - public ?string $currency, - public ?string $transactionsDataCurrentAsOf, - public ?string $balanceDataCurrentAsOf, - public ?string $customUserProvidedIdentifier, - public ?string $balanceCents, - public ?string $availableBalanceCents, + public readonly ?string $maskedAccountNumber, + public readonly ?string $currency, + public readonly ?string $transactionsDataCurrentAsOf, + public readonly ?string $balanceDataCurrentAsOf, + public readonly ?string $customUserProvidedIdentifier, + public readonly ?string $balanceCents, + public readonly ?string $availableBalanceCents, ) { } diff --git a/packages/php/src/Models/AccountList.php b/packages/php/src/Models/AccountList.php index df56c1a..8cdca33 100644 --- a/packages/php/src/Models/AccountList.php +++ b/packages/php/src/Models/AccountList.php @@ -5,15 +5,15 @@ namespace Tesote\Sdk\Models; /** Response from GET /v1/accounts and GET /v2/accounts. */ -final readonly class AccountList +final class AccountList { /** * @param list $accounts */ public function __construct( - public int $total, - public array $accounts, - public PagePagination $pagination, + public readonly int $total, + public readonly array $accounts, + public readonly PagePagination $pagination, ) { } diff --git a/packages/php/src/Models/Bank.php b/packages/php/src/Models/Bank.php index 39d16b7..ddafb36 100644 --- a/packages/php/src/Models/Bank.php +++ b/packages/php/src/Models/Bank.php @@ -5,10 +5,10 @@ namespace Tesote\Sdk\Models; /** Account.bank — minimal bank descriptor (name only on the wire). */ -final readonly class Bank +final class Bank { public function __construct( - public string $name, + public readonly string $name, ) { } diff --git a/packages/php/src/Models/BatchActionResult.php b/packages/php/src/Models/BatchActionResult.php index 64d4f09..2078a78 100644 --- a/packages/php/src/Models/BatchActionResult.php +++ b/packages/php/src/Models/BatchActionResult.php @@ -10,18 +10,18 @@ * Each endpoint returns a slightly different mix of counters, kept on a single * value object so callers can branch on whichever fields are populated. */ -final readonly class BatchActionResult +final class BatchActionResult { /** * @param list> $errors */ public function __construct( - public ?int $approved, - public ?int $enqueued, - public ?int $cancelled, - public ?int $skipped, - public ?int $failed, - public array $errors, + public readonly ?int $approved, + public readonly ?int $enqueued, + public readonly ?int $cancelled, + public readonly ?int $skipped, + public readonly ?int $failed, + public readonly array $errors, ) { } diff --git a/packages/php/src/Models/BatchCreated.php b/packages/php/src/Models/BatchCreated.php index 68597d7..6ef0587 100644 --- a/packages/php/src/Models/BatchCreated.php +++ b/packages/php/src/Models/BatchCreated.php @@ -5,16 +5,16 @@ namespace Tesote\Sdk\Models; /** Response from POST /v2/accounts/{id}/batches. */ -final readonly class BatchCreated +final class BatchCreated { /** * @param list $orders * @param list> $errors */ public function __construct( - public string $batchId, - public array $orders, - public array $errors, + public readonly string $batchId, + public readonly array $orders, + public readonly array $errors, ) { } diff --git a/packages/php/src/Models/BatchSummary.php b/packages/php/src/Models/BatchSummary.php index 8ce7963..da0ddb1 100644 --- a/packages/php/src/Models/BatchSummary.php +++ b/packages/php/src/Models/BatchSummary.php @@ -5,21 +5,21 @@ namespace Tesote\Sdk\Models; /** Response from GET /v2/accounts/{id}/batches/{batch_id}. */ -final readonly class BatchSummary +final class BatchSummary { /** * @param array $statuses * @param list $orders */ public function __construct( - public string $batchId, - public int $totalOrders, - public int $totalAmountCents, - public string $amountCurrency, - public array $statuses, - public string $batchStatus, - public string $createdAt, - public array $orders, + public readonly string $batchId, + public readonly int $totalOrders, + public readonly int $totalAmountCents, + public readonly string $amountCurrency, + public readonly array $statuses, + public readonly string $batchStatus, + public readonly string $createdAt, + public readonly array $orders, ) { } diff --git a/packages/php/src/Models/BulkAccountResult.php b/packages/php/src/Models/BulkAccountResult.php index a5d151e..716ce31 100644 --- a/packages/php/src/Models/BulkAccountResult.php +++ b/packages/php/src/Models/BulkAccountResult.php @@ -5,15 +5,15 @@ namespace Tesote\Sdk\Models; /** One row from the bulk_results array of POST /v2/transactions/bulk. */ -final readonly class BulkAccountResult +final class BulkAccountResult { /** * @param list $transactions */ public function __construct( - public string $accountId, - public array $transactions, - public CursorPagination $pagination, + public readonly string $accountId, + public readonly array $transactions, + public readonly CursorPagination $pagination, ) { } diff --git a/packages/php/src/Models/BulkResult.php b/packages/php/src/Models/BulkResult.php index 16355bb..82e9182 100644 --- a/packages/php/src/Models/BulkResult.php +++ b/packages/php/src/Models/BulkResult.php @@ -5,13 +5,13 @@ namespace Tesote\Sdk\Models; /** Response from POST /v2/transactions/bulk. */ -final readonly class BulkResult +final class BulkResult { /** * @param list $bulkResults */ public function __construct( - public array $bulkResults, + public readonly array $bulkResults, ) { } diff --git a/packages/php/src/Models/Counterparty.php b/packages/php/src/Models/Counterparty.php index 5d59a2e..2e7a287 100644 --- a/packages/php/src/Models/Counterparty.php +++ b/packages/php/src/Models/Counterparty.php @@ -11,11 +11,11 @@ * carries the id. Both shapes parse via fromArray() — id stays null when * absent. */ -final readonly class Counterparty +final class Counterparty { public function __construct( - public ?string $id, - public string $name, + public readonly ?string $id, + public readonly string $name, ) { } diff --git a/packages/php/src/Models/CursorPagination.php b/packages/php/src/Models/CursorPagination.php index 8b62525..5702e67 100644 --- a/packages/php/src/Models/CursorPagination.php +++ b/packages/php/src/Models/CursorPagination.php @@ -5,13 +5,13 @@ namespace Tesote\Sdk\Models; /** Cursor pagination block (used by transactions index endpoints). */ -final readonly class CursorPagination +final class CursorPagination { public function __construct( - public bool $hasMore, - public int $perPage, - public ?string $afterId, - public ?string $beforeId, + public readonly bool $hasMore, + public readonly int $perPage, + public readonly ?string $afterId, + public readonly ?string $beforeId, ) { } diff --git a/packages/php/src/Models/Destination.php b/packages/php/src/Models/Destination.php index d0c314b..cbb096a 100644 --- a/packages/php/src/Models/Destination.php +++ b/packages/php/src/Models/Destination.php @@ -5,12 +5,12 @@ namespace Tesote\Sdk\Models; /** TransactionOrder.destination — beneficiary identifiers. */ -final readonly class Destination +final class Destination { public function __construct( - public string $paymentMethodId, - public ?string $counterpartyId, - public ?string $counterpartyName, + public readonly string $paymentMethodId, + public readonly ?string $counterpartyId, + public readonly ?string $counterpartyName, ) { } diff --git a/packages/php/src/Models/ExportFile.php b/packages/php/src/Models/ExportFile.php index 14a1e54..de8e39d 100644 --- a/packages/php/src/Models/ExportFile.php +++ b/packages/php/src/Models/ExportFile.php @@ -11,12 +11,12 @@ * `format` echoes back the requested format. `filename` reflects the server's * Content-Disposition suggestion (or null if the SDK couldn't parse it). */ -final readonly class ExportFile +final class ExportFile { public function __construct( - public string $body, - public string $format, - public ?string $filename, + public readonly string $body, + public readonly string $format, + public readonly ?string $filename, ) { } } diff --git a/packages/php/src/Models/LegalEntity.php b/packages/php/src/Models/LegalEntity.php index cad2b55..972cf2a 100644 --- a/packages/php/src/Models/LegalEntity.php +++ b/packages/php/src/Models/LegalEntity.php @@ -5,11 +5,11 @@ namespace Tesote\Sdk\Models; /** Account.legal_entity — owning legal entity (id and legal_name may be null). */ -final readonly class LegalEntity +final class LegalEntity { public function __construct( - public ?string $id, - public ?string $legalName, + public readonly ?string $id, + public readonly ?string $legalName, ) { } diff --git a/packages/php/src/Models/Money.php b/packages/php/src/Models/Money.php index 84a0894..669fac8 100644 --- a/packages/php/src/Models/Money.php +++ b/packages/php/src/Models/Money.php @@ -10,11 +10,11 @@ * Amount stored as string for decimal-safety (matches the wire). Callers that * need numeric comparison should parse with bcmath / ext-decimal. */ -final readonly class Money +final class Money { public function __construct( - public string $amount, - public string $currency, + public readonly string $amount, + public readonly string $currency, ) { } diff --git a/packages/php/src/Models/PagePagination.php b/packages/php/src/Models/PagePagination.php index 6ddcc14..12daac3 100644 --- a/packages/php/src/Models/PagePagination.php +++ b/packages/php/src/Models/PagePagination.php @@ -5,13 +5,13 @@ namespace Tesote\Sdk\Models; /** Page-based pagination block (used by accounts list endpoints). */ -final readonly class PagePagination +final class PagePagination { public function __construct( - public int $currentPage, - public int $perPage, - public int $totalPages, - public int $totalCount, + public readonly int $currentPage, + public readonly int $perPage, + public readonly int $totalPages, + public readonly int $totalCount, ) { } diff --git a/packages/php/src/Models/PaymentMethod.php b/packages/php/src/Models/PaymentMethod.php index 30d22b9..9106b8a 100644 --- a/packages/php/src/Models/PaymentMethod.php +++ b/packages/php/src/Models/PaymentMethod.php @@ -5,24 +5,24 @@ namespace Tesote\Sdk\Models; /** PaymentMethod (v2). details is type-specific so kept as a generic map. */ -final readonly class PaymentMethod +final class PaymentMethod { /** * @param array $details */ public function __construct( - public string $id, - public string $methodType, - public string $currency, - public ?string $label, - public array $details, - public bool $verified, - public ?string $verifiedAt, - public ?string $lastUsedAt, - public ?Counterparty $counterparty, - public ?TesoteAccountRef $tesoteAccount, - public string $createdAt, - public string $updatedAt, + public readonly string $id, + public readonly string $methodType, + public readonly string $currency, + public readonly ?string $label, + public readonly array $details, + public readonly bool $verified, + public readonly ?string $verifiedAt, + public readonly ?string $lastUsedAt, + public readonly ?Counterparty $counterparty, + public readonly ?TesoteAccountRef $tesoteAccount, + public readonly string $createdAt, + public readonly string $updatedAt, ) { } diff --git a/packages/php/src/Models/PaymentMethodList.php b/packages/php/src/Models/PaymentMethodList.php index dba92f7..616c5da 100644 --- a/packages/php/src/Models/PaymentMethodList.php +++ b/packages/php/src/Models/PaymentMethodList.php @@ -5,16 +5,16 @@ namespace Tesote\Sdk\Models; /** Response from GET /v2/payment_methods. */ -final readonly class PaymentMethodList +final class PaymentMethodList { /** * @param list $items */ public function __construct( - public array $items, - public bool $hasMore, - public int $limit, - public int $offset, + public readonly array $items, + public readonly bool $hasMore, + public readonly int $limit, + public readonly int $offset, ) { } diff --git a/packages/php/src/Models/SourceAccount.php b/packages/php/src/Models/SourceAccount.php index ef4b927..7cc3c40 100644 --- a/packages/php/src/Models/SourceAccount.php +++ b/packages/php/src/Models/SourceAccount.php @@ -5,12 +5,12 @@ namespace Tesote\Sdk\Models; /** TransactionOrder.source_account — the account funding the order. */ -final readonly class SourceAccount +final class SourceAccount { public function __construct( - public string $id, - public string $name, - public string $paymentMethodId, + public readonly string $id, + public readonly string $name, + public readonly string $paymentMethodId, ) { } diff --git a/packages/php/src/Models/StatusInfo.php b/packages/php/src/Models/StatusInfo.php index 8b00474..ef208c9 100644 --- a/packages/php/src/Models/StatusInfo.php +++ b/packages/php/src/Models/StatusInfo.php @@ -5,11 +5,11 @@ namespace Tesote\Sdk\Models; /** Response from GET /status and GET /v2/status. */ -final readonly class StatusInfo +final class StatusInfo { public function __construct( - public string $status, - public bool $authenticated, + public readonly string $status, + public readonly bool $authenticated, ) { } diff --git a/packages/php/src/Models/SyncRemoval.php b/packages/php/src/Models/SyncRemoval.php index c257b83..00de89d 100644 --- a/packages/php/src/Models/SyncRemoval.php +++ b/packages/php/src/Models/SyncRemoval.php @@ -5,11 +5,11 @@ namespace Tesote\Sdk\Models; /** Entry in the `removed` array from the /v2/transactions/sync response. */ -final readonly class SyncRemoval +final class SyncRemoval { public function __construct( - public string $transactionId, - public string $accountId, + public readonly string $transactionId, + public readonly string $accountId, ) { } diff --git a/packages/php/src/Models/SyncResult.php b/packages/php/src/Models/SyncResult.php index e1207ac..877e48f 100644 --- a/packages/php/src/Models/SyncResult.php +++ b/packages/php/src/Models/SyncResult.php @@ -5,7 +5,7 @@ namespace Tesote\Sdk\Models; /** Result envelope returned by POST /v2/.../transactions/sync. */ -final readonly class SyncResult +final class SyncResult { /** * @param list $added @@ -13,11 +13,11 @@ * @param list $removed */ public function __construct( - public array $added, - public array $modified, - public array $removed, - public ?string $nextCursor, - public bool $hasMore, + public readonly array $added, + public readonly array $modified, + public readonly array $removed, + public readonly ?string $nextCursor, + public readonly bool $hasMore, ) { } diff --git a/packages/php/src/Models/SyncSession.php b/packages/php/src/Models/SyncSession.php index a8d2416..4f655b6 100644 --- a/packages/php/src/Models/SyncSession.php +++ b/packages/php/src/Models/SyncSession.php @@ -5,21 +5,21 @@ namespace Tesote\Sdk\Models; /** SyncSession (one row from /v2/accounts/{id}/sync_sessions). */ -final readonly class SyncSession +final class SyncSession { /** * @param array{type: string, message: string}|null $error * @param array{total_duration: float, complexity_score: float, sync_speed_score: float}|null $performance */ public function __construct( - public string $id, - public string $status, - public string $startedAt, - public ?string $completedAt, - public int $transactionsSynced, - public int $accountsCount, - public ?array $error, - public ?array $performance, + public readonly string $id, + public readonly string $status, + public readonly string $startedAt, + public readonly ?string $completedAt, + public readonly int $transactionsSynced, + public readonly int $accountsCount, + public readonly ?array $error, + public readonly ?array $performance, ) { } diff --git a/packages/php/src/Models/SyncSessionList.php b/packages/php/src/Models/SyncSessionList.php index 2b3a0a2..2d905b5 100644 --- a/packages/php/src/Models/SyncSessionList.php +++ b/packages/php/src/Models/SyncSessionList.php @@ -5,16 +5,16 @@ namespace Tesote\Sdk\Models; /** Response from GET /v2/accounts/{id}/sync_sessions. */ -final readonly class SyncSessionList +final class SyncSessionList { /** * @param list $syncSessions */ public function __construct( - public array $syncSessions, - public int $limit, - public int $offset, - public bool $hasMore, + public readonly array $syncSessions, + public readonly int $limit, + public readonly int $offset, + public readonly bool $hasMore, ) { } diff --git a/packages/php/src/Models/SyncStarted.php b/packages/php/src/Models/SyncStarted.php index 010a32f..be2e5d9 100644 --- a/packages/php/src/Models/SyncStarted.php +++ b/packages/php/src/Models/SyncStarted.php @@ -5,13 +5,13 @@ namespace Tesote\Sdk\Models; /** Response envelope returned by POST /v2/accounts/{id}/sync (202 Accepted). */ -final readonly class SyncStarted +final class SyncStarted { public function __construct( - public string $message, - public string $syncSessionId, - public string $status, - public string $startedAt, + public readonly string $message, + public readonly string $syncSessionId, + public readonly string $status, + public readonly string $startedAt, ) { } diff --git a/packages/php/src/Models/SyncTransaction.php b/packages/php/src/Models/SyncTransaction.php index d95e23d..e3efc6c 100644 --- a/packages/php/src/Models/SyncTransaction.php +++ b/packages/php/src/Models/SyncTransaction.php @@ -8,24 +8,24 @@ * SyncTransaction — flattened, Plaid-compatible shape used by the * /v2/.../transactions/sync endpoints. Distinct from Transaction. */ -final readonly class SyncTransaction +final class SyncTransaction { /** * @param list $category */ public function __construct( - public string $transactionId, - public string $accountId, - public float $amount, - public string $isoCurrencyCode, - public ?string $unofficialCurrencyCode, - public string $date, - public ?string $datetime, - public string $name, - public ?string $merchantName, - public bool $pending, - public array $category, - public ?int $runningBalanceCents, + public readonly string $transactionId, + public readonly string $accountId, + public readonly float $amount, + public readonly string $isoCurrencyCode, + public readonly ?string $unofficialCurrencyCode, + public readonly string $date, + public readonly ?string $datetime, + public readonly string $name, + public readonly ?string $merchantName, + public readonly bool $pending, + public readonly array $category, + public readonly ?int $runningBalanceCents, ) { } diff --git a/packages/php/src/Models/TesoteAccountRef.php b/packages/php/src/Models/TesoteAccountRef.php index 3242622..22de8d1 100644 --- a/packages/php/src/Models/TesoteAccountRef.php +++ b/packages/php/src/Models/TesoteAccountRef.php @@ -5,11 +5,11 @@ namespace Tesote\Sdk\Models; /** PaymentMethod.tesote_account — back-reference to a source account. */ -final readonly class TesoteAccountRef +final class TesoteAccountRef { public function __construct( - public string $id, - public string $name, + public readonly string $id, + public readonly string $name, ) { } diff --git a/packages/php/src/Models/TesoteTransactionRef.php b/packages/php/src/Models/TesoteTransactionRef.php index 4efa87b..b4a5029 100644 --- a/packages/php/src/Models/TesoteTransactionRef.php +++ b/packages/php/src/Models/TesoteTransactionRef.php @@ -5,11 +5,11 @@ namespace Tesote\Sdk\Models; /** TransactionOrder.tesote_transaction — reference to the underlying ledger transaction. */ -final readonly class TesoteTransactionRef +final class TesoteTransactionRef { public function __construct( - public string $id, - public string $status, + public readonly string $id, + public readonly string $status, ) { } diff --git a/packages/php/src/Models/Transaction.php b/packages/php/src/Models/Transaction.php index 27f5ff5..49859e0 100644 --- a/packages/php/src/Models/Transaction.php +++ b/packages/php/src/Models/Transaction.php @@ -5,19 +5,19 @@ namespace Tesote\Sdk\Models; /** Transaction (v1 schema — also returned by GET /v2/transactions/{id}). */ -final readonly class Transaction +final class Transaction { /** * @param list $transactionCategories */ public function __construct( - public string $id, - public string $status, - public TransactionData $data, - public string $tesoteImportedAt, - public string $tesoteUpdatedAt, - public array $transactionCategories, - public ?Counterparty $counterparty, + public readonly string $id, + public readonly string $status, + public readonly TransactionData $data, + public readonly string $tesoteImportedAt, + public readonly string $tesoteUpdatedAt, + public readonly array $transactionCategories, + public readonly ?Counterparty $counterparty, ) { } diff --git a/packages/php/src/Models/TransactionCategory.php b/packages/php/src/Models/TransactionCategory.php index 8bcc6c3..5c172f7 100644 --- a/packages/php/src/Models/TransactionCategory.php +++ b/packages/php/src/Models/TransactionCategory.php @@ -5,13 +5,13 @@ namespace Tesote\Sdk\Models; /** Transaction.transaction_categories[] entry. */ -final readonly class TransactionCategory +final class TransactionCategory { public function __construct( - public string $name, - public ?string $externalCategoryCode, - public string $createdAt, - public string $updatedAt, + public readonly string $name, + public readonly ?string $externalCategoryCode, + public readonly string $createdAt, + public readonly string $updatedAt, ) { } diff --git a/packages/php/src/Models/TransactionData.php b/packages/php/src/Models/TransactionData.php index 5aac68a..713a32a 100644 --- a/packages/php/src/Models/TransactionData.php +++ b/packages/php/src/Models/TransactionData.php @@ -10,18 +10,18 @@ * runningBalanceCents only present when the workspace has running-balance * display enabled and the caller opted in. */ -final readonly class TransactionData +final class TransactionData { public function __construct( - public int $amountCents, - public string $currency, - public string $description, - public string $transactionDate, - public ?string $createdAt, - public ?string $createdAtDate, - public ?string $note, - public ?string $externalServiceId, - public ?int $runningBalanceCents, + public readonly int $amountCents, + public readonly string $currency, + public readonly string $description, + public readonly string $transactionDate, + public readonly ?string $createdAt, + public readonly ?string $createdAtDate, + public readonly ?string $note, + public readonly ?string $externalServiceId, + public readonly ?int $runningBalanceCents, ) { } diff --git a/packages/php/src/Models/TransactionList.php b/packages/php/src/Models/TransactionList.php index a773763..18b85ff 100644 --- a/packages/php/src/Models/TransactionList.php +++ b/packages/php/src/Models/TransactionList.php @@ -5,15 +5,15 @@ namespace Tesote\Sdk\Models; /** Response from GET /v1/accounts/{id}/transactions and GET /v2/accounts/{id}/transactions. */ -final readonly class TransactionList +final class TransactionList { /** * @param list $transactions */ public function __construct( - public int $total, - public array $transactions, - public CursorPagination $pagination, + public readonly int $total, + public readonly array $transactions, + public readonly CursorPagination $pagination, ) { } diff --git a/packages/php/src/Models/TransactionOrder.php b/packages/php/src/Models/TransactionOrder.php index 3d0ca46..252d5cd 100644 --- a/packages/php/src/Models/TransactionOrder.php +++ b/packages/php/src/Models/TransactionOrder.php @@ -5,32 +5,32 @@ namespace Tesote\Sdk\Models; /** TransactionOrder model (v2). */ -final readonly class TransactionOrder +final class TransactionOrder { public function __construct( - public string $id, - public string $status, - public string $amount, - public string $currency, - public string $description, - public ?string $reference, - public ?string $externalReference, - public ?string $idempotencyKey, - public ?string $batchId, - public ?string $scheduledFor, - public ?string $approvedAt, - public ?string $submittedAt, - public ?string $completedAt, - public ?string $failedAt, - public ?string $cancelledAt, - public SourceAccount $sourceAccount, - public Destination $destination, - public ?Money $fee, - public ?string $executionStrategy, - public ?TesoteTransactionRef $tesoteTransaction, - public ?TransactionOrderAttempt $latestAttempt, - public string $createdAt, - public string $updatedAt, + public readonly string $id, + public readonly string $status, + public readonly string $amount, + public readonly string $currency, + public readonly string $description, + public readonly ?string $reference, + public readonly ?string $externalReference, + public readonly ?string $idempotencyKey, + public readonly ?string $batchId, + public readonly ?string $scheduledFor, + public readonly ?string $approvedAt, + public readonly ?string $submittedAt, + public readonly ?string $completedAt, + public readonly ?string $failedAt, + public readonly ?string $cancelledAt, + public readonly SourceAccount $sourceAccount, + public readonly Destination $destination, + public readonly ?Money $fee, + public readonly ?string $executionStrategy, + public readonly ?TesoteTransactionRef $tesoteTransaction, + public readonly ?TransactionOrderAttempt $latestAttempt, + public readonly string $createdAt, + public readonly string $updatedAt, ) { } diff --git a/packages/php/src/Models/TransactionOrderAttempt.php b/packages/php/src/Models/TransactionOrderAttempt.php index 67aefe8..f9c038f 100644 --- a/packages/php/src/Models/TransactionOrderAttempt.php +++ b/packages/php/src/Models/TransactionOrderAttempt.php @@ -5,17 +5,17 @@ namespace Tesote\Sdk\Models; /** TransactionOrder.latest_attempt — most-recent execution attempt summary. */ -final readonly class TransactionOrderAttempt +final class TransactionOrderAttempt { public function __construct( - public string $id, - public string $status, - public int $attemptNumber, - public ?string $externalReference, - public ?string $submittedAt, - public ?string $completedAt, - public ?string $errorCode, - public ?string $errorMessage, + public readonly string $id, + public readonly string $status, + public readonly int $attemptNumber, + public readonly ?string $externalReference, + public readonly ?string $submittedAt, + public readonly ?string $completedAt, + public readonly ?string $errorCode, + public readonly ?string $errorMessage, ) { } diff --git a/packages/php/src/Models/TransactionOrderList.php b/packages/php/src/Models/TransactionOrderList.php index 47c77dc..5ad85e4 100644 --- a/packages/php/src/Models/TransactionOrderList.php +++ b/packages/php/src/Models/TransactionOrderList.php @@ -5,16 +5,16 @@ namespace Tesote\Sdk\Models; /** Response from GET /v2/accounts/{id}/transaction_orders. */ -final readonly class TransactionOrderList +final class TransactionOrderList { /** * @param list $items */ public function __construct( - public array $items, - public bool $hasMore, - public int $limit, - public int $offset, + public readonly array $items, + public readonly bool $hasMore, + public readonly int $limit, + public readonly int $offset, ) { } diff --git a/packages/php/src/Models/TransactionSearchResult.php b/packages/php/src/Models/TransactionSearchResult.php index d49c667..3f1d0b9 100644 --- a/packages/php/src/Models/TransactionSearchResult.php +++ b/packages/php/src/Models/TransactionSearchResult.php @@ -5,14 +5,14 @@ namespace Tesote\Sdk\Models; /** Response from GET /v2/transactions/search. */ -final readonly class TransactionSearchResult +final class TransactionSearchResult { /** * @param list $transactions */ public function __construct( - public array $transactions, - public int $total, + public readonly array $transactions, + public readonly int $total, ) { } diff --git a/packages/php/src/Models/WhoAmI.php b/packages/php/src/Models/WhoAmI.php index fefbc44..51421e8 100644 --- a/packages/php/src/Models/WhoAmI.php +++ b/packages/php/src/Models/WhoAmI.php @@ -5,12 +5,12 @@ namespace Tesote\Sdk\Models; /** Response from GET /whoami and GET /v2/whoami. */ -final readonly class WhoAmI +final class WhoAmI { public function __construct( - public string $id, - public string $name, - public string $type, + public readonly string $id, + public readonly string $name, + public readonly string $type, ) { } From 392999ba34a886044f80cfd175e231f38b48c8af Mon Sep 17 00:00:00 2001 From: sebi Date: Tue, 28 Apr 2026 19:53:50 -0500 Subject: [PATCH 10/10] coderabbit: trim tone_instructions to fit 250-char limit CodeRabbit failed to parse the config with "String must contain at most 250 character(s) at tone_instructions". The previous string was 317 chars. Drop the per-linter list (each language already has its own linter run in CI; doesn't need to be enumerated here). Co-Authored-By: Claude Opus 4.7 (1M context) --- .coderabbit.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 5118c56..c22bf89 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -9,7 +9,7 @@ language: en-US early_access: true enable_free_tier: true -tone_instructions: "Be concise and direct. Focus on bugs, security vulnerabilities, breaking changes to the public surface, and cross-language parity gaps. Skip nitpicks and style suggestions — each language has its own linter (biome, ruff, rubocop, golangci-lint, php-cs-fixer, gradle warnings). Only comment on code changed in this PR." +tone_instructions: "Be concise. Focus on bugs, security issues, breaking public-surface changes, and cross-language parity gaps. Skip style/nitpicks — each language has its own linter. Only comment on code changed in this PR." reviews: profile: chill