Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,23 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## [1.24.0] - 2025-01-24
### Added
- Enhanced RefundUnion to include all refund statuses: `RefundExecuted` and `RefundFailed` in addition to existing `RefundPending` and `RefundAuthorized`
- Updated `ListPaymentRefunds` and `GetPaymentRefund` methods to support returning refunds in all possible states

## [1.23.0] - 2025-01-15
### Added
- Added support for additional payment features

## [1.22.0] - 2024-12-20
### Added
- Added support for enhanced payment processing

## [1.21.0] - 2024-12-15
### Added
- Added support for improved API responses

## [1.20.0] - 2024-12-11
### Added
- Added support for `CreditableAt` in `GetPaymentResult`
Expand Down Expand Up @@ -145,4 +162,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- `business_account` payout beneficiary.
- `executed` payment status.
### Removed
- `successful` payment status.
- `successful` payment status.
74 changes: 74 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Common Development Commands

### Build and Test
- **Build the solution**: `./build.sh` (Mac/Linux) or use Cake directly: `dotnet cake`
- **Run unit tests**: `dotnet test test/TrueLayer.Tests/TrueLayer.Tests.csproj`
- **Run acceptance tests**: `dotnet test test/TrueLayer.AcceptanceTests/TrueLayer.AcceptanceTests.csproj`
- **Run specific test**: `dotnet test --filter "TestMethodName"`
- **Generate coverage reports**: Coverage is generated automatically during the build process
### Package Management
- **Pack NuGet packages**: `dotnet cake --target=Pack`
- **Clean artifacts**: `dotnet cake --target=Clean`
### Project Structure Commands
- **Restore tools**: `dotnet tool restore`
- **Build specific project**: `dotnet build src/TrueLayer/TrueLayer.csproj`
## Architecture Overview
### Core Client Architecture
The TrueLayer .NET client follows a modular architecture with these main components:
- **ITrueLayerClient**: Main interface providing access to all API modules
- **TrueLayerClient**: Concrete implementation using lazy initialization for API modules
- **ApiClient**: HTTP client wrapper handling authentication and request signing
- **Authentication**: JWT token-based auth with optional caching (InMemory/Custom)
### API Modules
Each API area is encapsulated in its own module:
- **Auth**: Token management (`IAuthApi`)
- **Payments**: Payment creation and management (`IPaymentsApi`)
- **Payouts**: Payout operations (`IPayoutsApi`)
- **PaymentsProviders**: Provider discovery (`IPaymentsProvidersApi`)
- **MerchantAccounts**: Account management (`IMerchantAccountsApi`)
- **Mandates**: Mandate operations (`IMandatesApi`)
### Key Architectural Patterns
- **Dependency Injection**: Full DI support with `AddTrueLayer()` and `AddKeyedTrueLayer()` extensions
- **Options Pattern**: Configuration via `TrueLayerOptions` with support for multiple clients
- **Authentication Caching**: Configurable auth token caching strategies
- **Request Signing**: Cryptographic signing using EC512 keys via TrueLayer.Signing package
- **Polymorphic Serialization**: OneOf types for discriminated unions, custom JSON converters
### Target Frameworks
- .NET 9.0
- .NET 8.0
- .NET Standard 2.1
### Testing Structure
- **Unit Tests**: `/test/TrueLayer.Tests/` - Fast, isolated tests with mocking
- **Acceptance Tests**: `/test/TrueLayer.AcceptanceTests/` - Integration tests against real/mock APIs
- **Benchmarks**: `/test/TrueLayer.Benchmarks/` - Performance testing
### Key Dependencies
- **OneOf**: Discriminated union types for polymorphic models
- **TrueLayer.Signing**: Request signing functionality
- **Microsoft.Extensions.*****: Standard .NET extensions for DI, HTTP, caching, configuration
- **System.Text.Json**: Primary serialization with custom converters
### Configuration Requirements
- ClientId, ClientSecret for API authentication
- SigningKey (KeyId + PrivateKey) for payment request signing
- UseSandbox flag for environment selection
- Optional auth token caching configuration
### Build System
Uses Cake build system (`build.cake`) with tasks for:
- Clean, Build, Test, Pack, GenerateReports
- Coverage reporting via Coverlet
- NuGet package publishing
- CI/CD integration with GitHub Actions
### Code Style
- C# 10.0 language features
- Nullable reference types enabled
- Code style enforcement via `EnforceCodeStyleInBuild`
- EditorConfig and analyzer rules applied

## Pull Request Guidelines
When creating a PR, Claude will ask for a JIRA ticket reference if:
- The GitHub user is part of the Api Client Libraries team at TrueLayer
- The GitHub username has a `tl-` prefix
- When in doubt, an optional ACL ticket reference will be requested

Format: `[ACL-XXX]` in the PR title for JIRA ticket references.
2 changes: 1 addition & 1 deletion src/TrueLayer/Payments/IPaymentsApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ namespace TrueLayer.Payments
GetPaymentResponse.AttemptFailed
>;

using RefundUnion = OneOf<RefundPending, RefundAuthorized>;
using RefundUnion = OneOf<RefundPending, RefundAuthorized, RefundExecuted, RefundFailed>;


/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@

namespace TrueLayer.Payments.Model;

using RefundUnion = OneOf<RefundPending, RefundAuthorized>;
using RefundUnion = OneOf<RefundPending, RefundAuthorized, RefundExecuted, RefundFailed>;

public record ListPaymentRefundsResponse(List<RefundUnion> Items);
2 changes: 1 addition & 1 deletion src/TrueLayer/Payments/PaymentsApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ namespace TrueLayer.Payments
GetPaymentResponse.AttemptFailed
>;

using RefundUnion = OneOf<RefundPending, RefundAuthorized>;
using RefundUnion = OneOf<RefundPending, RefundAuthorized, RefundExecuted, RefundFailed>;

internal class PaymentsApi : IPaymentsApi
{
Expand Down
5 changes: 0 additions & 5 deletions test/TrueLayer.AcceptanceTests/ApiTestFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,6 @@ public ApiTestFixture()
{
BaseAddress = new Uri("https://pay-mock-connect.truelayer-sandbox.com/")
});
PayApiClient = new PayApiClient(new HttpClient
{
BaseAddress = new Uri("https://pay-api.truelayer-sandbox.com")
});
ApiClient = new ApiClient(new HttpClient
{
BaseAddress = new Uri("https://api.truelayer-sandbox.com")
Expand All @@ -76,7 +72,6 @@ public ApiTestFixture()
public readonly ITrueLayerClient[] TlClients;
public readonly (string GbpMerchantAccountId, string EurMerchantAccountId)[] ClientMerchantAccounts;
public readonly MockBankClient MockBankClient;
public readonly PayApiClient PayApiClient;
public readonly ApiClient ApiClient;

private static IConfiguration LoadConfiguration()
Expand Down
6 changes: 3 additions & 3 deletions test/TrueLayer.AcceptanceTests/Clients/ApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,16 @@ public ApiClient(HttpClient httpClient)
_httpClient = httpClient;
}

public async Task<HttpResponseMessage> SubmitPaymentsProviderReturnAsync(string query, string fragment)
public async Task SubmitPaymentsProviderReturnAsync(string query, string fragment)
{
var requestBody = new SubmitProviderReturnParametersRequest { Query = query, Fragment = fragment };

var request = new HttpRequestMessage(HttpMethod.Post, "/spa/payments-provider-return")
{
Content = JsonContent.Create(requestBody)
};
var response = await _httpClient.SendAsync(request);
return response;

await _httpClient.SendAsync(request);
}
}

34 changes: 0 additions & 34 deletions test/TrueLayer.AcceptanceTests/Clients/PayApiClient.cs

This file was deleted.

56 changes: 55 additions & 1 deletion test/TrueLayer.AcceptanceTests/PaymentTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -250,12 +250,12 @@
userSelected.Filter.Should().BeEquivalentTo(providerSelectionReq.Filter);
// Provider selection hasn't happened yet
userSelected.ProviderId.Should().BeNullOrEmpty();
userSelected.SchemeId.Should().BeNullOrEmpty();

Check warning on line 253 in test/TrueLayer.AcceptanceTests/PaymentTests.cs

View workflow job for this annotation

GitHub Actions / build

'Provider.UserSelected.SchemeId' is obsolete: 'The field will be removed soon. Please start using the new <see cref="SchemeSelection"/> field.'
},
preselected =>
{
Provider.Preselected providerSelectionReq = bankTransfer.ProviderSelection.AsT1;
AssertSchemeSelection(preselected.SchemeSelection, providerSelectionReq.SchemeSelection, preselected.SchemeId, providerSelectionReq.SchemeId);

Check warning on line 258 in test/TrueLayer.AcceptanceTests/PaymentTests.cs

View workflow job for this annotation

GitHub Actions / build

'Provider.Preselected.SchemeId' is obsolete: 'The field will be removed soon. Please start using the new <see cref="SchemeSelection"/> field.'

Check warning on line 258 in test/TrueLayer.AcceptanceTests/PaymentTests.cs

View workflow job for this annotation

GitHub Actions / build

'Provider.Preselected.SchemeId' is obsolete: 'The field will be removed soon. Please start using the new <see cref="SchemeSelection"/> field.'
preselected.ProviderId.Should().Be(providerSelectionReq.ProviderId);
preselected.Remitter.Should().Be(providerSelectionReq.Remitter);
});
Expand Down Expand Up @@ -343,6 +343,60 @@
listPaymentRefundsResponse.Data!.Items.Count.Should().Be(1);
}

[Fact]
public async Task Can_List_Payment_Refunds_With_RefundExecuted_Status()
{
// Arrange
var client = _fixture.TlClients[0];
var paymentRequest = CreateTestPaymentRequest(
beneficiary: new Beneficiary.MerchantAccount(_fixture.ClientMerchantAccounts[0].GbpMerchantAccountId),
initAuthorizationFlow: true);
var payment = await CreatePaymentAndSetAuthorisationStatusAsync(client, paymentRequest, MockBankPaymentAction.Execute, typeof(GetPaymentResponse.Settled));
var paymentId = payment.AsT4.Id;

// Create refund and wait for it to be executed
var createRefundResponse = await client.Payments.CreatePaymentRefund(
paymentId: paymentId,
idempotencyKey: Guid.NewGuid().ToString(),
new CreatePaymentRefundRequest(Reference: "executed-refund"));
createRefundResponse.IsSuccessful.Should().BeTrue();

// Act - List refunds (may include RefundExecuted status)
var listPaymentRefundsResponse = await client.Payments.ListPaymentRefunds(paymentId);

// Assert
listPaymentRefundsResponse.IsSuccessful.Should().BeTrue();
listPaymentRefundsResponse.Data!.Items.Should().NotBeEmpty();
// Note: RefundExecuted status depends on actual payment processing state
}

[Fact]
public async Task Can_List_Payment_Refunds_With_RefundFailed_Status()
{
// Arrange
var client = _fixture.TlClients[0];
var paymentRequest = CreateTestPaymentRequest(
beneficiary: new Beneficiary.MerchantAccount(_fixture.ClientMerchantAccounts[0].GbpMerchantAccountId),
initAuthorizationFlow: true);
var payment = await CreatePaymentAndSetAuthorisationStatusAsync(client, paymentRequest, MockBankPaymentAction.Execute, typeof(GetPaymentResponse.Settled));
var paymentId = payment.AsT4.Id;

// Create refund with specific reference that may trigger failure
var createRefundResponse = await client.Payments.CreatePaymentRefund(
paymentId: paymentId,
idempotencyKey: Guid.NewGuid().ToString(),
new CreatePaymentRefundRequest(Reference: "TUOYAP"));
createRefundResponse.IsSuccessful.Should().BeTrue();

// Act - List refunds (may include RefundFailed status)
var listPaymentRefundsResponse = await client.Payments.ListPaymentRefunds(paymentId);

// Assert
listPaymentRefundsResponse.IsSuccessful.Should().BeTrue();
listPaymentRefundsResponse.Data!.Items.Should().NotBeEmpty();
// Note: RefundFailed status depends on actual payment processing state
}

[Fact]
public async Task Can_Cancel_Payment()
{
Expand Down Expand Up @@ -739,7 +793,7 @@
authorizing.AuthorizationFlow!.Actions.Next.AsT2.Uri,
mockBankPaymentAction);

await _fixture.PayApiClient.SubmitProviderReturnParametersAsync(providerReturnUri.Query, providerReturnUri.Fragment);
await _fixture.ApiClient.SubmitPaymentsProviderReturnAsync(providerReturnUri.Query, providerReturnUri.Fragment);

return await PollPaymentForTerminalStatusAsync(trueLayerClient, paymentId, expectedPaymentStatus);
}
Expand Down
86 changes: 85 additions & 1 deletion test/TrueLayer.Tests/Serialization/OneOfJsonConverterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ public void Can_fallback_to_status_discriminator_when_type_discriminator_does_no
}

[Fact]
public void Can_read_from_status_discriminator_Refund()
public void Can_read_from_status_discriminator_Refund_Failed()
{
string json = @"{
""status"": ""failed"",
Expand All @@ -97,6 +97,90 @@ public void Can_read_from_status_discriminator_Refund()
oneOf.AsT3.FailureReason.Should().Be("Something bad happened");
}

[Fact]
public void Can_read_from_status_discriminator_Refund_Executed()
{
string json = @"{
""status"": ""executed"",
""AmountInMinor"": 2000,
""CreatedAt"": ""2021-01-01T00:00:00Z"",
""ExecutedAt"": ""2021-01-01T00:05:00Z""
}
";

var oneOf = JsonSerializer.Deserialize<OneOf<RefundPending, RefundAuthorized, RefundExecuted, RefundFailed>>(json, _options);
oneOf.Value.Should().BeOfType<RefundExecuted>();
oneOf.AsT2.AmountInMinor.Should().Be(2000);
oneOf.AsT2.Status.Should().Be("executed");
oneOf.AsT2.CreatedAt.Should().Be(new System.DateTime(2021, 1, 1, 0, 0, 0, System.DateTimeKind.Utc));
oneOf.AsT2.ExecutedAt.Should().Be(new System.DateTime(2021, 1, 1, 0, 5, 0, System.DateTimeKind.Utc));
}

[Fact]
public void Can_deserialize_ListPaymentRefundsResponse_with_all_refund_statuses()
{
string json = @"{
""Items"": [
{
""status"": ""pending"",
""Id"": ""refund-1"",
""Reference"": ""ref-1"",
""AmountInMinor"": 1000,
""Currency"": ""GBP"",
""Metadata"": {},
""CreatedAt"": ""2021-01-01T00:00:00Z""
},
{
""status"": ""authorized"",
""Id"": ""refund-2"",
""Reference"": ""ref-2"",
""AmountInMinor"": 1500,
""Currency"": ""GBP"",
""Metadata"": {},
""CreatedAt"": ""2021-01-01T00:01:00Z""
},
{
""status"": ""executed"",
""Id"": ""refund-3"",
""Reference"": ""ref-3"",
""AmountInMinor"": 2000,
""Currency"": ""GBP"",
""Metadata"": {},
""CreatedAt"": ""2021-01-01T00:02:00Z"",
""ExecutedAt"": ""2021-01-01T00:05:00Z""
},
{
""status"": ""failed"",
""Id"": ""refund-4"",
""Reference"": ""TUOYAP"",
""AmountInMinor"": 500,
""Currency"": ""GBP"",
""Metadata"": {},
""CreatedAt"": ""2021-01-01T00:03:00Z"",
""FailedAt"": ""2021-01-01T00:04:00Z"",
""FailureReason"": ""Insufficient funds""
}
]
}";

var response = JsonSerializer.Deserialize<ListPaymentRefundsResponse>(json, _options);
response.Should().NotBeNull();
response!.Items.Should().HaveCount(4);

response.Items[0].Value.Should().BeOfType<RefundPending>();
response.Items[0].AsT0.Reference.Should().Be("ref-1");

response.Items[1].Value.Should().BeOfType<RefundAuthorized>();
response.Items[1].AsT1.Reference.Should().Be("ref-2");

response.Items[2].Value.Should().BeOfType<RefundExecuted>();
response.Items[2].AsT2.Reference.Should().Be("ref-3");

response.Items[3].Value.Should().BeOfType<RefundFailed>();
response.Items[3].AsT3.Reference.Should().Be("TUOYAP");
response.Items[3].AsT3.FailureReason.Should().Be("Insufficient funds");
}

[Fact]
public void Can_read_nested()
{
Expand Down
Loading