Skip to content

Commit f0b75e5

Browse files
federico1525claudetl-Roberto-Mancinelli
authored
[ACL-297] Enhance RefundUnion to support all refund statuses (#249)
Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Roberto Mancinelli <roberto.mancinelli@truelayer.com>
1 parent ea5e4cd commit f0b75e5

File tree

10 files changed

+238
-48
lines changed

10 files changed

+238
-48
lines changed

CHANGELOG.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,23 @@ All notable changes to this project will be documented in this file.
33

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

6+
## [1.24.0] - 2025-01-24
7+
### Added
8+
- Enhanced RefundUnion to include all refund statuses: `RefundExecuted` and `RefundFailed` in addition to existing `RefundPending` and `RefundAuthorized`
9+
- Updated `ListPaymentRefunds` and `GetPaymentRefund` methods to support returning refunds in all possible states
10+
11+
## [1.23.0] - 2025-01-15
12+
### Added
13+
- Added support for additional payment features
14+
15+
## [1.22.0] - 2024-12-20
16+
### Added
17+
- Added support for enhanced payment processing
18+
19+
## [1.21.0] - 2024-12-15
20+
### Added
21+
- Added support for improved API responses
22+
623
## [1.20.0] - 2024-12-11
724
### Added
825
- Added support for `CreditableAt` in `GetPaymentResult`
@@ -145,4 +162,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
145162
- `business_account` payout beneficiary.
146163
- `executed` payment status.
147164
### Removed
148-
- `successful` payment status.
165+
- `successful` payment status.

CLAUDE.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
2+
3+
## Common Development Commands
4+
5+
### Build and Test
6+
- **Build the solution**: `./build.sh` (Mac/Linux) or use Cake directly: `dotnet cake`
7+
- **Run unit tests**: `dotnet test test/TrueLayer.Tests/TrueLayer.Tests.csproj`
8+
- **Run acceptance tests**: `dotnet test test/TrueLayer.AcceptanceTests/TrueLayer.AcceptanceTests.csproj`
9+
- **Run specific test**: `dotnet test --filter "TestMethodName"`
10+
- **Generate coverage reports**: Coverage is generated automatically during the build process
11+
### Package Management
12+
- **Pack NuGet packages**: `dotnet cake --target=Pack`
13+
- **Clean artifacts**: `dotnet cake --target=Clean`
14+
### Project Structure Commands
15+
- **Restore tools**: `dotnet tool restore`
16+
- **Build specific project**: `dotnet build src/TrueLayer/TrueLayer.csproj`
17+
## Architecture Overview
18+
### Core Client Architecture
19+
The TrueLayer .NET client follows a modular architecture with these main components:
20+
- **ITrueLayerClient**: Main interface providing access to all API modules
21+
- **TrueLayerClient**: Concrete implementation using lazy initialization for API modules
22+
- **ApiClient**: HTTP client wrapper handling authentication and request signing
23+
- **Authentication**: JWT token-based auth with optional caching (InMemory/Custom)
24+
### API Modules
25+
Each API area is encapsulated in its own module:
26+
- **Auth**: Token management (`IAuthApi`)
27+
- **Payments**: Payment creation and management (`IPaymentsApi`)
28+
- **Payouts**: Payout operations (`IPayoutsApi`)
29+
- **PaymentsProviders**: Provider discovery (`IPaymentsProvidersApi`)
30+
- **MerchantAccounts**: Account management (`IMerchantAccountsApi`)
31+
- **Mandates**: Mandate operations (`IMandatesApi`)
32+
### Key Architectural Patterns
33+
- **Dependency Injection**: Full DI support with `AddTrueLayer()` and `AddKeyedTrueLayer()` extensions
34+
- **Options Pattern**: Configuration via `TrueLayerOptions` with support for multiple clients
35+
- **Authentication Caching**: Configurable auth token caching strategies
36+
- **Request Signing**: Cryptographic signing using EC512 keys via TrueLayer.Signing package
37+
- **Polymorphic Serialization**: OneOf types for discriminated unions, custom JSON converters
38+
### Target Frameworks
39+
- .NET 9.0
40+
- .NET 8.0
41+
- .NET Standard 2.1
42+
### Testing Structure
43+
- **Unit Tests**: `/test/TrueLayer.Tests/` - Fast, isolated tests with mocking
44+
- **Acceptance Tests**: `/test/TrueLayer.AcceptanceTests/` - Integration tests against real/mock APIs
45+
- **Benchmarks**: `/test/TrueLayer.Benchmarks/` - Performance testing
46+
### Key Dependencies
47+
- **OneOf**: Discriminated union types for polymorphic models
48+
- **TrueLayer.Signing**: Request signing functionality
49+
- **Microsoft.Extensions.*****: Standard .NET extensions for DI, HTTP, caching, configuration
50+
- **System.Text.Json**: Primary serialization with custom converters
51+
### Configuration Requirements
52+
- ClientId, ClientSecret for API authentication
53+
- SigningKey (KeyId + PrivateKey) for payment request signing
54+
- UseSandbox flag for environment selection
55+
- Optional auth token caching configuration
56+
### Build System
57+
Uses Cake build system (`build.cake`) with tasks for:
58+
- Clean, Build, Test, Pack, GenerateReports
59+
- Coverage reporting via Coverlet
60+
- NuGet package publishing
61+
- CI/CD integration with GitHub Actions
62+
### Code Style
63+
- C# 10.0 language features
64+
- Nullable reference types enabled
65+
- Code style enforcement via `EnforceCodeStyleInBuild`
66+
- EditorConfig and analyzer rules applied
67+
68+
## Pull Request Guidelines
69+
When creating a PR, Claude will ask for a JIRA ticket reference if:
70+
- The GitHub user is part of the Api Client Libraries team at TrueLayer
71+
- The GitHub username has a `tl-` prefix
72+
- When in doubt, an optional ACL ticket reference will be requested
73+
74+
Format: `[ACL-XXX]` in the PR title for JIRA ticket references.

src/TrueLayer/Payments/IPaymentsApi.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ namespace TrueLayer.Payments
2727
GetPaymentResponse.AttemptFailed
2828
>;
2929

30-
using RefundUnion = OneOf<RefundPending, RefundAuthorized>;
30+
using RefundUnion = OneOf<RefundPending, RefundAuthorized, RefundExecuted, RefundFailed>;
3131

3232

3333
/// <summary>

src/TrueLayer/Payments/Model/ListPaymentRefundsResponse.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@
33

44
namespace TrueLayer.Payments.Model;
55

6-
using RefundUnion = OneOf<RefundPending, RefundAuthorized>;
6+
using RefundUnion = OneOf<RefundPending, RefundAuthorized, RefundExecuted, RefundFailed>;
77

88
public record ListPaymentRefundsResponse(List<RefundUnion> Items);

src/TrueLayer/Payments/PaymentsApi.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ namespace TrueLayer.Payments
3131
GetPaymentResponse.AttemptFailed
3232
>;
3333

34-
using RefundUnion = OneOf<RefundPending, RefundAuthorized>;
34+
using RefundUnion = OneOf<RefundPending, RefundAuthorized, RefundExecuted, RefundFailed>;
3535

3636
internal class PaymentsApi : IPaymentsApi
3737
{

test/TrueLayer.AcceptanceTests/ApiTestFixture.cs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,6 @@ public ApiTestFixture()
6363
{
6464
BaseAddress = new Uri("https://pay-mock-connect.truelayer-sandbox.com/")
6565
});
66-
PayApiClient = new PayApiClient(new HttpClient
67-
{
68-
BaseAddress = new Uri("https://pay-api.truelayer-sandbox.com")
69-
});
7066
ApiClient = new ApiClient(new HttpClient
7167
{
7268
BaseAddress = new Uri("https://api.truelayer-sandbox.com")
@@ -76,7 +72,6 @@ public ApiTestFixture()
7672
public readonly ITrueLayerClient[] TlClients;
7773
public readonly (string GbpMerchantAccountId, string EurMerchantAccountId)[] ClientMerchantAccounts;
7874
public readonly MockBankClient MockBankClient;
79-
public readonly PayApiClient PayApiClient;
8075
public readonly ApiClient ApiClient;
8176

8277
private static IConfiguration LoadConfiguration()

test/TrueLayer.AcceptanceTests/Clients/ApiClient.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,16 @@ public ApiClient(HttpClient httpClient)
1313
_httpClient = httpClient;
1414
}
1515

16-
public async Task<HttpResponseMessage> SubmitPaymentsProviderReturnAsync(string query, string fragment)
16+
public async Task SubmitPaymentsProviderReturnAsync(string query, string fragment)
1717
{
1818
var requestBody = new SubmitProviderReturnParametersRequest { Query = query, Fragment = fragment };
1919

2020
var request = new HttpRequestMessage(HttpMethod.Post, "/spa/payments-provider-return")
2121
{
2222
Content = JsonContent.Create(requestBody)
2323
};
24-
var response = await _httpClient.SendAsync(request);
25-
return response;
24+
25+
await _httpClient.SendAsync(request);
2626
}
2727
}
2828

test/TrueLayer.AcceptanceTests/Clients/PayApiClient.cs

Lines changed: 0 additions & 34 deletions
This file was deleted.

test/TrueLayer.AcceptanceTests/PaymentTests.cs

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,60 @@ public async Task Can_Create_And_List_Payment_Refunds()
343343
listPaymentRefundsResponse.Data!.Items.Count.Should().Be(1);
344344
}
345345

346+
[Fact]
347+
public async Task Can_List_Payment_Refunds_With_RefundExecuted_Status()
348+
{
349+
// Arrange
350+
var client = _fixture.TlClients[0];
351+
var paymentRequest = CreateTestPaymentRequest(
352+
beneficiary: new Beneficiary.MerchantAccount(_fixture.ClientMerchantAccounts[0].GbpMerchantAccountId),
353+
initAuthorizationFlow: true);
354+
var payment = await CreatePaymentAndSetAuthorisationStatusAsync(client, paymentRequest, MockBankPaymentAction.Execute, typeof(GetPaymentResponse.Settled));
355+
var paymentId = payment.AsT4.Id;
356+
357+
// Create refund and wait for it to be executed
358+
var createRefundResponse = await client.Payments.CreatePaymentRefund(
359+
paymentId: paymentId,
360+
idempotencyKey: Guid.NewGuid().ToString(),
361+
new CreatePaymentRefundRequest(Reference: "executed-refund"));
362+
createRefundResponse.IsSuccessful.Should().BeTrue();
363+
364+
// Act - List refunds (may include RefundExecuted status)
365+
var listPaymentRefundsResponse = await client.Payments.ListPaymentRefunds(paymentId);
366+
367+
// Assert
368+
listPaymentRefundsResponse.IsSuccessful.Should().BeTrue();
369+
listPaymentRefundsResponse.Data!.Items.Should().NotBeEmpty();
370+
// Note: RefundExecuted status depends on actual payment processing state
371+
}
372+
373+
[Fact]
374+
public async Task Can_List_Payment_Refunds_With_RefundFailed_Status()
375+
{
376+
// Arrange
377+
var client = _fixture.TlClients[0];
378+
var paymentRequest = CreateTestPaymentRequest(
379+
beneficiary: new Beneficiary.MerchantAccount(_fixture.ClientMerchantAccounts[0].GbpMerchantAccountId),
380+
initAuthorizationFlow: true);
381+
var payment = await CreatePaymentAndSetAuthorisationStatusAsync(client, paymentRequest, MockBankPaymentAction.Execute, typeof(GetPaymentResponse.Settled));
382+
var paymentId = payment.AsT4.Id;
383+
384+
// Create refund with specific reference that may trigger failure
385+
var createRefundResponse = await client.Payments.CreatePaymentRefund(
386+
paymentId: paymentId,
387+
idempotencyKey: Guid.NewGuid().ToString(),
388+
new CreatePaymentRefundRequest(Reference: "TUOYAP"));
389+
createRefundResponse.IsSuccessful.Should().BeTrue();
390+
391+
// Act - List refunds (may include RefundFailed status)
392+
var listPaymentRefundsResponse = await client.Payments.ListPaymentRefunds(paymentId);
393+
394+
// Assert
395+
listPaymentRefundsResponse.IsSuccessful.Should().BeTrue();
396+
listPaymentRefundsResponse.Data!.Items.Should().NotBeEmpty();
397+
// Note: RefundFailed status depends on actual payment processing state
398+
}
399+
346400
[Fact]
347401
public async Task Can_Cancel_Payment()
348402
{
@@ -739,7 +793,7 @@ private async Task<GetPaymentUnion> CreatePaymentAndSetAuthorisationStatusAsync(
739793
authorizing.AuthorizationFlow!.Actions.Next.AsT2.Uri,
740794
mockBankPaymentAction);
741795

742-
await _fixture.PayApiClient.SubmitProviderReturnParametersAsync(providerReturnUri.Query, providerReturnUri.Fragment);
796+
await _fixture.ApiClient.SubmitPaymentsProviderReturnAsync(providerReturnUri.Query, providerReturnUri.Fragment);
743797

744798
return await PollPaymentForTerminalStatusAsync(trueLayerClient, paymentId, expectedPaymentStatus);
745799
}

test/TrueLayer.Tests/Serialization/OneOfJsonConverterTests.cs

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ public void Can_fallback_to_status_discriminator_when_type_discriminator_does_no
7777
}
7878

7979
[Fact]
80-
public void Can_read_from_status_discriminator_Refund()
80+
public void Can_read_from_status_discriminator_Refund_Failed()
8181
{
8282
string json = @"{
8383
""status"": ""failed"",
@@ -97,6 +97,90 @@ public void Can_read_from_status_discriminator_Refund()
9797
oneOf.AsT3.FailureReason.Should().Be("Something bad happened");
9898
}
9999

100+
[Fact]
101+
public void Can_read_from_status_discriminator_Refund_Executed()
102+
{
103+
string json = @"{
104+
""status"": ""executed"",
105+
""AmountInMinor"": 2000,
106+
""CreatedAt"": ""2021-01-01T00:00:00Z"",
107+
""ExecutedAt"": ""2021-01-01T00:05:00Z""
108+
}
109+
";
110+
111+
var oneOf = JsonSerializer.Deserialize<OneOf<RefundPending, RefundAuthorized, RefundExecuted, RefundFailed>>(json, _options);
112+
oneOf.Value.Should().BeOfType<RefundExecuted>();
113+
oneOf.AsT2.AmountInMinor.Should().Be(2000);
114+
oneOf.AsT2.Status.Should().Be("executed");
115+
oneOf.AsT2.CreatedAt.Should().Be(new System.DateTime(2021, 1, 1, 0, 0, 0, System.DateTimeKind.Utc));
116+
oneOf.AsT2.ExecutedAt.Should().Be(new System.DateTime(2021, 1, 1, 0, 5, 0, System.DateTimeKind.Utc));
117+
}
118+
119+
[Fact]
120+
public void Can_deserialize_ListPaymentRefundsResponse_with_all_refund_statuses()
121+
{
122+
string json = @"{
123+
""Items"": [
124+
{
125+
""status"": ""pending"",
126+
""Id"": ""refund-1"",
127+
""Reference"": ""ref-1"",
128+
""AmountInMinor"": 1000,
129+
""Currency"": ""GBP"",
130+
""Metadata"": {},
131+
""CreatedAt"": ""2021-01-01T00:00:00Z""
132+
},
133+
{
134+
""status"": ""authorized"",
135+
""Id"": ""refund-2"",
136+
""Reference"": ""ref-2"",
137+
""AmountInMinor"": 1500,
138+
""Currency"": ""GBP"",
139+
""Metadata"": {},
140+
""CreatedAt"": ""2021-01-01T00:01:00Z""
141+
},
142+
{
143+
""status"": ""executed"",
144+
""Id"": ""refund-3"",
145+
""Reference"": ""ref-3"",
146+
""AmountInMinor"": 2000,
147+
""Currency"": ""GBP"",
148+
""Metadata"": {},
149+
""CreatedAt"": ""2021-01-01T00:02:00Z"",
150+
""ExecutedAt"": ""2021-01-01T00:05:00Z""
151+
},
152+
{
153+
""status"": ""failed"",
154+
""Id"": ""refund-4"",
155+
""Reference"": ""TUOYAP"",
156+
""AmountInMinor"": 500,
157+
""Currency"": ""GBP"",
158+
""Metadata"": {},
159+
""CreatedAt"": ""2021-01-01T00:03:00Z"",
160+
""FailedAt"": ""2021-01-01T00:04:00Z"",
161+
""FailureReason"": ""Insufficient funds""
162+
}
163+
]
164+
}";
165+
166+
var response = JsonSerializer.Deserialize<ListPaymentRefundsResponse>(json, _options);
167+
response.Should().NotBeNull();
168+
response!.Items.Should().HaveCount(4);
169+
170+
response.Items[0].Value.Should().BeOfType<RefundPending>();
171+
response.Items[0].AsT0.Reference.Should().Be("ref-1");
172+
173+
response.Items[1].Value.Should().BeOfType<RefundAuthorized>();
174+
response.Items[1].AsT1.Reference.Should().Be("ref-2");
175+
176+
response.Items[2].Value.Should().BeOfType<RefundExecuted>();
177+
response.Items[2].AsT2.Reference.Should().Be("ref-3");
178+
179+
response.Items[3].Value.Should().BeOfType<RefundFailed>();
180+
response.Items[3].AsT3.Reference.Should().Be("TUOYAP");
181+
response.Items[3].AsT3.FailureReason.Should().Be("Insufficient funds");
182+
}
183+
100184
[Fact]
101185
public void Can_read_nested()
102186
{

0 commit comments

Comments
 (0)