diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 7f75933a..c21765fe 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -6,7 +6,15 @@ "version": "5.1.0", "commands": [ "dotnet-cake" - ] + ], + "rollForward": false + }, + "docfx": { + "version": "2.78.4", + "commands": [ + "docfx" + ], + "rollForward": false } } } diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..09451474 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,59 @@ +name: Documentation + +on: + push: + branches: [ main ] + paths: + - 'docs/**' + - 'src/**/*.cs' + - '.github/workflows/docs.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: Setup Pages + uses: actions/configure-pages@v5 + + - name: Restore tools + run: dotnet tool restore + + - name: Restore dependencies + run: dotnet restore + + - name: Build documentation + run: dotnet docfx docs/docfx.json + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs/_site + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index c3ce4c41..5b93b973 100644 --- a/.gitignore +++ b/.gitignore @@ -442,3 +442,14 @@ $RECYCLE.BIN/ !.vscode/extensions.json appsettings.local*.json + +## +## DocFX +## +# DocFX build output +docs/_site/ +docs/api/ +docs/obj/ + +# DocFX log files +docs/*.log diff --git a/CLAUDE.md b/CLAUDE.md index b3cc8949..2e22aced 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Common Development Commands ### Build and Test -- **Build the solution**: `./build.sh` (Mac/Linux) or use Cake directly: `dotnet cake` +- **Build the solution**: `dotnet build` - **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"` @@ -60,7 +60,7 @@ Uses Cake build system (`build.cake`) with tasks for: - NuGet package publishing - CI/CD integration with GitHub Actions ### Code Style -- C# 10.0 language features +- C# 12.0 language features - Nullable reference types enabled - Code style enforcement via `EnforceCodeStyleInBuild` - EditorConfig and analyzer rules applied diff --git a/build.cake b/build.cake index d01a07d5..a7b6f22a 100644 --- a/build.cake +++ b/build.cake @@ -1,10 +1,7 @@ // Install .NET Core Global tools. -#tool "dotnet:?package=dotnet-reportgenerator-globaltool&version=5.4.9" +#tool "dotnet:?package=dotnet-reportgenerator-globaltool&version=5.4.18" #tool "dotnet:?package=dotnet-sonarscanner&version=11.0.0" -#tool "dotnet:?package=dotnet-reportgenerator-globaltool&version=5.4.17" -#tool "dotnet:?package=coveralls.net&version=4.0.1" - // Install addins #addin nuget:?package=Cake.Coverlet&version=5.1.1 #addin nuget:?package=Cake.Sonar&version=5.0.0 @@ -228,7 +225,6 @@ public static class BuildContext public static bool IsTag { get; private set; } public static string NugetApiUrl { get; private set; } public static string NugetApiKey { get; private set; } - public static bool ForcePushDocs { get; private set; } public static bool ShouldPublishToNuget => !string.IsNullOrWhiteSpace(BuildContext.NugetApiUrl) && !string.IsNullOrWhiteSpace(BuildContext.NugetApiKey); @@ -256,8 +252,6 @@ public static class BuildContext NugetApiUrl = context.EnvironmentVariable("NUGET_PRE_API_URL"); NugetApiKey = context.EnvironmentVariable("NUGET_PRE_API_KEY"); } - - ForcePushDocs = context.Argument("force-docs", false); } public static void PrintParameters(ICakeContext context) diff --git a/docs/articles/authentication.md b/docs/articles/authentication.md new file mode 100644 index 00000000..5934fa48 --- /dev/null +++ b/docs/articles/authentication.md @@ -0,0 +1,240 @@ +# Authentication + +The TrueLayer .NET client handles authentication automatically using your Client ID and Secret. However, you can optimize performance using auth token caching strategies. + +## Basic Configuration + +Configure authentication in your `appsettings.json`: + +```json +{ + "TrueLayer": { + "ClientId": "your-client-id", + "ClientSecret": "your-client-secret", + "UseSandbox": true + } +} +``` + +## Token Caching Strategies + +Auth tokens have a limited lifetime. Caching them reduces API calls and improves performance. + +### In-Memory Caching + +Recommended for most applications: + +```csharp +services.AddTrueLayer( + configuration, + options => { /* config */ }, + authTokenCachingStrategy: AuthTokenCachingStrategies.InMemory +); +``` + +### Custom Caching + +Implement `IAuthTokenCache` for distributed caching: + +```csharp +public class RedisAuthTokenCache : IAuthTokenCache +{ + private readonly IDistributedCache _cache; + + public RedisAuthTokenCache(IDistributedCache cache) + { + _cache = cache; + } + + public async Task GetAsync(string key, CancellationToken ct = default) + { + return await _cache.GetStringAsync(key, ct); + } + + public async Task SetAsync( + string key, + string value, + TimeSpan expiry, + CancellationToken ct = default) + { + await _cache.SetStringAsync( + key, + value, + new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = expiry }, + ct + ); + } +} + +// Register +services.AddSingleton(); +services.AddTrueLayer( + configuration, + options => { /* config */ }, + authTokenCachingStrategy: AuthTokenCachingStrategies.Custom +); +``` + +## Environment Configuration + +### Sandbox vs Production + +Control which environment to use: + +```csharp +// Use sandbox +"TrueLayer": { + "UseSandbox": true +} + +// Use production +"TrueLayer": { + "UseSandbox": false +} +``` + +### Custom API URIs + +Override default endpoints: + +```csharp +services.AddTrueLayer(configuration, options => +{ + options.Auth = new ApiOptions + { + Uri = new Uri("https://custom-auth.truelayer.com") + }; +}); +``` + +## Signing Keys for Payments + +Payment requests must be cryptographically signed: + +```csharp +services.AddTrueLayer(configuration, options => +{ + if (options.Payments?.SigningKey != null) + { + // Load from secure storage (e.g., Azure Key Vault, AWS Secrets Manager) + options.Payments.SigningKey.PrivateKey = await secretManager.GetSecretAsync("truelayer-private-key"); + } +}); +``` + +> [!WARNING] +> Never hardcode private keys or commit them to source control. + +## Multiple Clients + +Configure multiple clients with different credentials: + +```csharp +services + .AddKeyedTrueLayer("GBP", configuration, options => + { + options.ClientId = "gbp-client-id"; + options.ClientSecret = "gbp-secret"; + }) + .AddKeyedTrueLayer("EUR", configuration, options => + { + options.ClientId = "eur-client-id"; + options.ClientSecret = "eur-secret"; + }); +``` + +Usage: + +```csharp +public class PaymentService +{ + private readonly ITrueLayerClient _gbpClient; + private readonly ITrueLayerClient _eurClient; + + public PaymentService( + [FromKeyedServices("GBP")] ITrueLayerClient gbpClient, + [FromKeyedServices("EUR")] ITrueLayerClient eurClient) + { + _gbpClient = gbpClient; + _eurClient = eurClient; + } +} +``` + +## Security Best Practices + +### 1. Secure Credential Storage + +Use secret management services: + +```csharp +// Azure Key Vault +var keyVault = new SecretClient( + new Uri("https://your-keyvault.vault.azure.net/"), + new DefaultAzureCredential() +); + +var clientSecret = await keyVault.GetSecretAsync("TrueLayer-ClientSecret"); +var privateKey = await keyVault.GetSecretAsync("TrueLayer-PrivateKey"); + +services.AddTrueLayer(configuration, options => +{ + options.ClientSecret = clientSecret.Value.Value; + if (options.Payments?.SigningKey != null) + { + options.Payments.SigningKey.PrivateKey = privateKey.Value.Value; + } +}); +``` + +### 2. Rotate Credentials Regularly + +Implement credential rotation: + +```csharp +services.AddOptions() + .Configure((options, secretManager) => + { + // Reload credentials periodically + options.ClientSecret = secretManager.GetSecret("ClientSecret"); + }); +``` + +### 3. Limit Token Lifetime + +Configure shorter cache durations for sensitive environments: + +```csharp +services.AddSingleton(sp => + new CustomAuthTokenCache(maxLifetime: TimeSpan.FromMinutes(30)) +); +``` + +### 4. Use HTTPS Only + +Ensure all traffic is encrypted (default behavior). + +## Troubleshooting + +### 401 Unauthorized + +- Verify Client ID and Secret are correct +- Check credentials are for the correct environment (sandbox/production) +- Ensure credentials haven't expired + +### 403 Forbidden + +- Verify your application has the required API permissions in TrueLayer Console +- Check signing key is uploaded and Key ID is correct + +### Token Cache Issues + +- Clear cache and retry +- Verify cache implementation is working correctly +- Check cache expiry times + +## See Also + +- [Installation](installation.md) +- [Multiple Clients](multiple-clients.md) +- [Configuration Reference](configuration.md) diff --git a/docs/articles/configuration.md b/docs/articles/configuration.md new file mode 100644 index 00000000..058de124 --- /dev/null +++ b/docs/articles/configuration.md @@ -0,0 +1,699 @@ +# Configuration + +Advanced configuration options for customizing the TrueLayer .NET client to suit your application's needs. + +## TrueLayerOptions Overview + +The `TrueLayerOptions` class provides comprehensive configuration for the TrueLayer client: + +```csharp +public class TrueLayerOptions +{ + // Required: Your TrueLayer credentials + public string ClientId { get; set; } + public string ClientSecret { get; set; } + + // Environment selection + public bool UseSandbox { get; set; } = false; + + // API endpoints (optional - uses defaults if not set) + public ApiOptions Auth { get; set; } + public PaymentsOptions Payments { get; set; } + public ApiOptions MerchantAccounts { get; set; } + public ApiOptions Payouts { get; set; } +} +``` + +For more details, see the [TrueLayer API documentation](https://docs.truelayer.com/docs). + +## Basic Configuration + +### Configuration from appsettings.json + +The simplest configuration using `appsettings.json`: + +```json +{ + "TrueLayer": { + "ClientId": "your-client-id", + "ClientSecret": "your-client-secret", + "UseSandbox": true, + "Payments": { + "SigningKey": { + "KeyId": "your-key-id" + } + } + } +} +``` + +```csharp +services.AddTrueLayer(configuration, options => +{ + if (options.Payments?.SigningKey != null) + { + // Load private key from file + options.Payments.SigningKey.PrivateKey = File.ReadAllText("ec512-private-key.pem"); + } +}); +``` + +### Manual Configuration + +Configure options programmatically: + +```csharp +services.AddTrueLayer(configuration, options => +{ + options.ClientId = "your-client-id"; + options.ClientSecret = "your-client-secret"; + options.UseSandbox = true; + + if (options.Payments?.SigningKey != null) + { + options.Payments.SigningKey.KeyId = "your-key-id"; + options.Payments.SigningKey.PrivateKey = privateKeyContent; + } +}); +``` + +## Environment Configuration + +### Sandbox vs Production + +Switch between sandbox and production environments: + +```csharp +public static class TrueLayerConfiguration +{ + public static IServiceCollection ConfigureTrueLayer( + this IServiceCollection services, + IConfiguration configuration, + bool useSandbox) + { + services.AddTrueLayer(configuration, options => + { + options.UseSandbox = useSandbox; + + if (options.Payments?.SigningKey != null) + { + // Use different keys for different environments + var keyFile = useSandbox + ? "ec512-sandbox-private-key.pem" + : "ec512-production-private-key.pem"; + + options.Payments.SigningKey.PrivateKey = File.ReadAllText(keyFile); + } + }); + + return services; + } +} + +// Usage +if (builder.Environment.IsDevelopment()) +{ + services.ConfigureTrueLayer(configuration, useSandbox: true); +} +else +{ + services.ConfigureTrueLayer(configuration, useSandbox: false); +} +``` + +### Environment-Specific Configuration + +Use different configurations per environment: + +```csharp +// appsettings.Development.json +{ + "TrueLayer": { + "UseSandbox": true, + "ClientId": "sandbox-client-id", + "ClientSecret": "sandbox-client-secret" + } +} + +// appsettings.Production.json +{ + "TrueLayer": { + "UseSandbox": false, + "ClientId": "production-client-id", + "ClientSecret": "production-client-secret" + } +} +``` + +## Custom API Endpoints + +### Override Default Endpoints + +Configure custom API endpoints (useful for testing or proxying): + +```csharp +services.AddTrueLayer(configuration, options => +{ + // Custom auth endpoint + options.Auth = new ApiOptions + { + Uri = new Uri("https://custom-auth.truelayer.com") + }; + + // Custom payments endpoint + if (options.Payments != null) + { + options.Payments.Uri = new Uri("https://custom-api.truelayer.com"); + } + + // Custom merchant accounts endpoint + options.MerchantAccounts = new ApiOptions + { + Uri = new Uri("https://custom-api.truelayer.com") + }; + + // Custom payouts endpoint + options.Payouts = new ApiOptions + { + Uri = new Uri("https://custom-api.truelayer.com") + }; +}); +``` + +### Proxy Configuration + +Route requests through a proxy: + +```csharp +services.AddTrueLayer(configuration, options => +{ + options.UseSandbox = true; + + // Configure all endpoints through proxy + var proxyUri = new Uri("https://proxy.yourdomain.com"); + + options.Auth = new ApiOptions { Uri = proxyUri }; + if (options.Payments != null) + { + options.Payments.Uri = proxyUri; + } + options.MerchantAccounts = new ApiOptions { Uri = proxyUri }; + options.Payouts = new ApiOptions { Uri = proxyUri }; +}); +``` + +## HTTP Client Configuration + +### Custom HttpClient + +Configure the underlying HttpClient for advanced scenarios: + +```csharp +services.AddTrueLayer(configuration, options => +{ + // Configuration happens here +}, +authTokenCachingStrategy: AuthTokenCachingStrategies.InMemory, +configureHttpClient: (httpClient) => +{ + // Custom timeout + httpClient.Timeout = TimeSpan.FromSeconds(60); + + // Custom headers + httpClient.DefaultRequestHeaders.Add("X-Custom-Header", "CustomValue"); + + // User agent + httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("MyApp/1.0"); +}); +``` + +### Multiple Named HttpClients + +Use named HttpClients for different scenarios: + +```csharp +// Configure default client with shorter timeout for quick operations +services.AddTrueLayer(configuration, options => { }, + authTokenCachingStrategy: AuthTokenCachingStrategies.InMemory, + configureHttpClient: client => + { + client.Timeout = TimeSpan.FromSeconds(30); + }); + +// Configure a separate client for long-running operations +services.AddKeyedTrueLayer("long-running", configuration, options => { }, + authTokenCachingStrategy: AuthTokenCachingStrategies.InMemory, + configureHttpClient: client => + { + client.Timeout = TimeSpan.FromMinutes(2); + }); +``` + +### Retry Policies + +Configure retry behavior using Polly (requires `Microsoft.Extensions.Http.Polly`): + +```csharp +using Polly; +using Polly.Extensions.Http; + +services.AddTrueLayer(configuration, options => { }); + +// Add retry policy to the named HttpClient +services.AddHttpClient(TrueLayerClient.HttpClientName) + .AddPolicyHandler(HttpPolicyExtensions + .HandleTransientHttpError() + .OrResult(msg => msg.StatusCode == System.Net.HttpStatusCode.TooManyRequests) + .WaitAndRetryAsync(3, retryAttempt => + TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)))); +``` + +## Signing Key Configuration + +### Loading from File + +Load private key from a PEM file: + +```csharp +services.AddTrueLayer(configuration, options => +{ + if (options.Payments?.SigningKey != null) + { + options.Payments.SigningKey.KeyId = configuration["TrueLayer:Payments:SigningKey:KeyId"]; + options.Payments.SigningKey.PrivateKey = File.ReadAllText("ec512-private-key.pem"); + } +}); +``` + +### Loading from Azure Key Vault + +Load private key from Azure Key Vault: + +```csharp +services.AddTrueLayer(configuration, async options => +{ + if (options.Payments?.SigningKey != null) + { + var keyVaultClient = new SecretClient( + new Uri("https://your-keyvault.vault.azure.net/"), + new DefaultAzureCredential() + ); + + var secret = await keyVaultClient.GetSecretAsync("truelayer-signing-key"); + + options.Payments.SigningKey.KeyId = configuration["TrueLayer:Payments:SigningKey:KeyId"]; + options.Payments.SigningKey.PrivateKey = secret.Value.Value; + } +}); +``` + +### Loading from AWS Secrets Manager + +Load private key from AWS Secrets Manager: + +```csharp +services.AddTrueLayer(configuration, async options => +{ + if (options.Payments?.SigningKey != null) + { + var client = new AmazonSecretsManagerClient(RegionEndpoint.EUWest1); + + var request = new GetSecretValueRequest + { + SecretId = "truelayer-signing-key" + }; + + var response = await client.GetSecretValueAsync(request); + + options.Payments.SigningKey.KeyId = configuration["TrueLayer:Payments:SigningKey:KeyId"]; + options.Payments.SigningKey.PrivateKey = response.SecretString; + } +}); +``` + +### Loading from Environment Variables + +Load from environment variables: + +```csharp +services.AddTrueLayer(configuration, options => +{ + if (options.Payments?.SigningKey != null) + { + options.Payments.SigningKey.KeyId = Environment.GetEnvironmentVariable("TRUELAYER_KEY_ID"); + options.Payments.SigningKey.PrivateKey = Environment.GetEnvironmentVariable("TRUELAYER_PRIVATE_KEY"); + } +}); +``` + +## Authentication Token Caching + +### In-Memory Caching + +Use built-in in-memory caching: + +```csharp +services.AddTrueLayer( + configuration, + options => { }, + authTokenCachingStrategy: AuthTokenCachingStrategies.InMemory +); +``` + +### Distributed Caching + +Implement custom caching with Redis or other distributed cache: + +```csharp +public class DistributedAuthTokenCache : IAuthTokenCache +{ + private readonly IDistributedCache _cache; + private readonly ILogger _logger; + + public DistributedAuthTokenCache( + IDistributedCache cache, + ILogger logger) + { + _cache = cache; + _logger = logger; + } + + public async Task GetAsync(CancellationToken cancellationToken = default) + { + try + { + var json = await _cache.GetStringAsync("truelayer_auth_token", cancellationToken); + + if (string.IsNullOrEmpty(json)) + { + return null; + } + + return JsonSerializer.Deserialize(json); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error retrieving auth token from cache"); + return null; + } + } + + public async Task SetAsync(AuthToken token, CancellationToken cancellationToken = default) + { + try + { + var json = JsonSerializer.Serialize(token); + + var options = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(token.ExpiresIn - 60) + }; + + await _cache.SetStringAsync("truelayer_auth_token", json, options, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error storing auth token in cache"); + } + } +} + +// Register +services.AddStackExchangeRedisCache(options => +{ + options.Configuration = configuration["Redis:ConnectionString"]; +}); + +services.AddSingleton(); + +services.AddTrueLayer( + configuration, + options => { }, + authTokenCachingStrategy: AuthTokenCachingStrategies.Custom +); +``` + +For more details, see [Authentication](authentication.md). + +## Multiple Client Configuration + +### Named Clients + +Configure multiple TrueLayer clients for different purposes: + +```csharp +// Primary client for customer transactions +services.AddTrueLayer(configuration, options => +{ + options.ClientId = configuration["TrueLayer:Primary:ClientId"]; + options.ClientSecret = configuration["TrueLayer:Primary:ClientSecret"]; + options.UseSandbox = false; +}); + +// Secondary client for internal operations +services.AddKeyedTrueLayer("internal", configuration, options => +{ + options.ClientId = configuration["TrueLayer:Internal:ClientId"]; + options.ClientSecret = configuration["TrueLayer:Internal:ClientSecret"]; + options.UseSandbox = true; +}); + +// Usage +public class PaymentService +{ + private readonly ITrueLayerClient _primaryClient; + private readonly ITrueLayerClient _internalClient; + + public PaymentService( + ITrueLayerClient primaryClient, + [FromKeyedServices("internal")] ITrueLayerClient internalClient) + { + _primaryClient = primaryClient; + _internalClient = internalClient; + } +} +``` + +For more details, see [Multiple Clients](multiple-clients.md). + +## Logging Configuration + +### Enable HTTP Logging + +Enable detailed HTTP request/response logging: + +```csharp +services.AddTrueLayer(configuration, options => { }); + +// Add HTTP logging +services.AddHttpClient(TrueLayerClient.HttpClientName) + .AddHttpMessageHandler(() => new LoggingHandler()); + +public class LoggingHandler : DelegatingHandler +{ + private readonly ILogger _logger; + + public LoggingHandler(ILogger logger) + { + _logger = logger; + } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + _logger.LogInformation("Request: {Method} {Uri}", request.Method, request.RequestUri); + + var response = await base.SendAsync(request, cancellationToken); + + _logger.LogInformation( + "Response: {StatusCode} for {Method} {Uri}", + response.StatusCode, + request.Method, + request.RequestUri + ); + + return response; + } +} +``` + +### Configure Logging Levels + +Configure logging levels in `appsettings.json`: + +```json +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "TrueLayer": "Debug", + "System.Net.Http": "Warning" + } + } +} +``` + +## Security Best Practices + +### Protect Credentials + +Never commit credentials to source control: + +```csharp +// ❌ Bad - hardcoded credentials +options.ClientId = "hardcoded-client-id"; +options.ClientSecret = "hardcoded-secret"; + +// ✅ Good - from configuration +options.ClientId = configuration["TrueLayer:ClientId"]; +options.ClientSecret = configuration["TrueLayer:ClientSecret"]; + +// ✅ Better - from secure storage (Key Vault, Secrets Manager) +options.ClientSecret = await GetSecretFromVault("truelayer-client-secret"); +``` + +### Validate Configuration + +Validate configuration on startup: + +```csharp +public static class TrueLayerConfigurationValidator +{ + public static void ValidateConfiguration(TrueLayerOptions options) + { + if (string.IsNullOrEmpty(options.ClientId)) + { + throw new InvalidOperationException("TrueLayer ClientId is required"); + } + + if (string.IsNullOrEmpty(options.ClientSecret)) + { + throw new InvalidOperationException("TrueLayer ClientSecret is required"); + } + + if (options.Payments?.SigningKey != null) + { + if (string.IsNullOrEmpty(options.Payments.SigningKey.KeyId)) + { + throw new InvalidOperationException("Payments SigningKey KeyId is required"); + } + + if (string.IsNullOrEmpty(options.Payments.SigningKey.PrivateKey)) + { + throw new InvalidOperationException("Payments SigningKey PrivateKey is required"); + } + } + } +} + +// Use in Startup +services.AddTrueLayer(configuration, options => +{ + // Configure options... + TrueLayerConfigurationValidator.ValidateConfiguration(options); +}); +``` + +## Configuration Examples + +### Minimal Production Configuration + +```csharp +services.AddTrueLayer(configuration, options => +{ + if (options.Payments?.SigningKey != null) + { + options.Payments.SigningKey.PrivateKey = File.ReadAllText("ec512-private-key.pem"); + } +}, +authTokenCachingStrategy: AuthTokenCachingStrategies.InMemory); +``` + +### Advanced Production Configuration + +```csharp +services.AddTrueLayer(configuration, async options => +{ + // Load credentials from Key Vault + var keyVault = new SecretClient( + new Uri(configuration["KeyVault:Uri"]), + new DefaultAzureCredential() + ); + + options.ClientSecret = (await keyVault.GetSecretAsync("truelayer-client-secret")).Value.Value; + + if (options.Payments?.SigningKey != null) + { + options.Payments.SigningKey.PrivateKey = + (await keyVault.GetSecretAsync("truelayer-private-key")).Value.Value; + } +}, +authTokenCachingStrategy: AuthTokenCachingStrategies.Custom, +configureHttpClient: client => +{ + client.Timeout = TimeSpan.FromSeconds(45); + client.DefaultRequestHeaders.UserAgent.ParseAdd("MyApp/1.0.0"); +}); + +// Configure distributed caching +services.AddStackExchangeRedisCache(options => +{ + options.Configuration = configuration["Redis:ConnectionString"]; +}); + +// Configure retry policy +services.AddHttpClient(TrueLayerClient.HttpClientName) + .AddPolicyHandler(HttpPolicyExtensions + .HandleTransientHttpError() + .WaitAndRetryAsync(3, retryAttempt => + TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)))); +``` + +## Troubleshooting + +### Configuration Validation Errors + +If you see configuration errors on startup: + +```csharp +// Add diagnostics +services.PostConfigure(options => +{ + var logger = LoggerFactory.Create(builder => builder.AddConsole()) + .CreateLogger(); + + logger.LogInformation("TrueLayer Configuration:"); + logger.LogInformation(" ClientId: {ClientId}", options.ClientId?.Substring(0, 8) + "..."); + logger.LogInformation(" UseSandbox: {UseSandbox}", options.UseSandbox); + logger.LogInformation(" HasSigningKey: {HasKey}", options.Payments?.SigningKey?.PrivateKey != null); +}); +``` + +### Connection Issues + +If experiencing connection issues: + +```csharp +// Increase timeout +services.AddTrueLayer(configuration, options => { }, + configureHttpClient: client => + { + client.Timeout = TimeSpan.FromSeconds(120); + }); + +// Add diagnostic logging +services.AddHttpClient(TrueLayerClient.HttpClientName) + .ConfigureHttpClient(client => + { + client.Timeout = TimeSpan.FromSeconds(120); + }) + .AddHttpMessageHandler(() => new DiagnosticHandler()); +``` + +## See Also + +- [Installation](installation.md) - Getting started with TrueLayer +- [Authentication](authentication.md) - Authentication and token caching +- [Multiple Clients](multiple-clients.md) - Using multiple TrueLayer clients +- [API Reference](xref:TrueLayer.TrueLayerOptions) diff --git a/docs/articles/error-handling.md b/docs/articles/error-handling.md new file mode 100644 index 00000000..58f1ce43 --- /dev/null +++ b/docs/articles/error-handling.md @@ -0,0 +1,194 @@ +# Error Handling + +All TrueLayer API calls return an `ApiResponse` or `ApiResponse` that includes status information and error details. + +## Checking for Success + +```csharp +var response = await _client.Payments.CreatePayment(request, idempotencyKey); + +if (response.IsSuccessful) +{ + // Handle success + var payment = response.Data; +} +else +{ + // Handle failure + var statusCode = response.StatusCode; + var problemDetails = response.Problem; +} +``` + +## Problem Details + +When a request fails, the `Problem` property contains detailed error information: + +```csharp +if (!response.IsSuccessful && response.Problem != null) +{ + Console.WriteLine($"Error: {response.Problem.Title}"); + Console.WriteLine($"Detail: {response.Problem.Detail}"); + Console.WriteLine($"Type: {response.Problem.Type}"); + Console.WriteLine($"TraceId: {response.TraceId}"); + + if (response.Problem.Errors != null) + { + foreach (var (field, messages) in response.Problem.Errors) + { + Console.WriteLine($"{field}: {string.Join(", ", messages)}"); + } + } +} +``` + +## Common HTTP Status Codes + +| Code | Meaning | Action | +|------|---------|--------| +| 200-299 | Success | Process response data | +| 400 | Bad Request | Check request validation errors | +| 401 | Unauthorized | Verify credentials | +| 403 | Forbidden | Check API permissions | +| 404 | Not Found | Resource doesn't exist | +| 409 | Conflict | Handle idempotency key collision | +| 429 | Rate Limited | Implement retry with backoff | +| 500-599 | Server Error | Retry with exponential backoff | + +## Using TraceId for Support + +Always log the `TraceId` for failed requests - TrueLayer support needs this to investigate issues: + +```csharp +_logger.LogError( + "Payment creation failed. TraceId: {TraceId}, Status: {StatusCode}, Error: {Error}", + response.TraceId, + response.StatusCode, + response.Problem?.Detail +); +``` + +## Working with OneOf Response Types + +Many API methods return discriminated unions using `OneOf`: + +```csharp +var response = await _client.Payments.GetPayment(paymentId); + +if (response.IsSuccessful) +{ + var result = response.Data.Match( + authRequired => $"Auth required: {authRequired.Status}", + authorizing => $"Authorizing: {authorizing.Status}", + authorized => $"Authorized: {authorized.Status}", + executed => $"Executed: {executed.Status}", + settled => $"Settled: {settled.Status}", + failed => $"Failed: {failed.FailureReason}", + attemptFailed => $"Attempt failed: {attemptFailed.FailureReason}" + ); +} +``` + +## Exception Handling + +The SDK doesn't throw exceptions for API errors - always check `IsSuccessful`. However, network errors and configuration issues may throw: + +```csharp +try +{ + var response = await _client.Payments.CreatePayment(request, idempotencyKey); + + if (!response.IsSuccessful) + { + // Handle API error + return Results.Problem( + detail: response.Problem?.Detail, + statusCode: (int)response.StatusCode + ); + } + + return Results.Ok(response.Data); +} +catch (HttpRequestException ex) +{ + // Network error + _logger.LogError(ex, "Network error calling TrueLayer API"); + return Results.Problem("Service temporarily unavailable"); +} +catch (TaskCanceledException ex) +{ + // Timeout + _logger.LogError(ex, "Request to TrueLayer API timed out"); + return Results.Problem("Request timed out"); +} +``` + +## Validation Errors + +Field-level validation errors are in `Problem.Errors`: + +```csharp +if (response.StatusCode == HttpStatusCode.BadRequest && response.Problem?.Errors != null) +{ + foreach (var (field, messages) in response.Problem.Errors) + { + modelState.AddModelError(field, string.Join("; ", messages)); + } + return View(model); +} +``` + +## Retry Strategies + +For transient failures, implement retry logic: + +```csharp +public async Task> WithRetry( + Func>> operation, + int maxRetries = 3) +{ + for (int i = 0; i < maxRetries; i++) + { + try + { + var response = await operation(); + + // Retry on server errors or rate limiting + if (response.StatusCode == HttpStatusCode.TooManyRequests || + (int)response.StatusCode >= 500) + { + if (i < maxRetries - 1) + { + var delay = TimeSpan.FromSeconds(Math.Pow(2, i)); + await Task.Delay(delay); + continue; + } + } + + return response; + } + catch (HttpRequestException) + { + if (i == maxRetries - 1) throw; + await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, i))); + } + } + + throw new InvalidOperationException("Retry logic error"); +} +``` + +## Best Practices + +1. **Always check `IsSuccessful`** before accessing `Data` +2. **Log `TraceId`** for all failed requests +3. **Handle all union cases** in `Match()` calls +4. **Don't swallow errors** - log and handle appropriately +5. **Use structured logging** with status codes and trace IDs +6. **Implement retries** for transient failures (429, 5xx) +7. **Validate input** before making API calls + +## See Also + +- [Payments Guide](payments.md) +- [API Reference](xref:TrueLayer.ApiResponse) diff --git a/docs/articles/installation.md b/docs/articles/installation.md new file mode 100644 index 00000000..31daf16c --- /dev/null +++ b/docs/articles/installation.md @@ -0,0 +1,134 @@ +# Installation + +## Requirements + +- .NET 9.0 or .NET 8.0 +- A TrueLayer developer account + +## Installing the Package + +### Using .NET CLI + +```bash +dotnet add package TrueLayer.Client +``` + +### Using Package Manager Console + +```powershell +Install-Package TrueLayer.Client +``` + +### Using NuGet CLI + +```bash +nuget install TrueLayer.Client +``` + +## Prerequisites + +Before you can use the TrueLayer .NET client, you need to: + +### 1. Create a TrueLayer Account + +[Sign up](https://console.truelayer.com/) for a developer account and create a new application. + +### 2. Obtain Credentials + +From the TrueLayer Console, obtain: +- **Client ID** +- **Client Secret** + +### 3. Generate Signing Keys + +For payment requests, you need to generate EC512 signing keys: + +```bash +# Generate private key +docker run --rm -v ${PWD}:/out -w /out -it alpine/openssl ecparam -genkey -name secp521r1 -noout -out ec512-private-key.pem + +# Generate public key +docker run --rm -v ${PWD}:/out -w /out -it alpine/openssl ec -in ec512-private-key.pem -pubout -out ec512-public-key.pem +``` + +> [!WARNING] +> Store your private key securely. Never commit it to source control. + +### 4. Upload Public Key + +1. Navigate to Payments settings in the TrueLayer Console +2. Upload your public key (`ec512-public-key.pem`) +3. Note the generated Key ID + +### 5. Configure Application + +Add your credentials to `appsettings.json`: + +```json +{ + "TrueLayer": { + "ClientId": "your-client-id", + "ClientSecret": "your-client-secret", + "UseSandbox": true, + "Payments": { + "SigningKey": { + "KeyId": "your-key-id" + } + } + } +} +``` + +### 6. Register Services + +In your `Program.cs` or `Startup.cs`: + +```csharp +services.AddTrueLayer(configuration, options => +{ + if (options.Payments?.SigningKey != null) + { + // Load private key from secure storage + options.Payments.SigningKey.PrivateKey = File.ReadAllText("ec512-private-key.pem"); + } +}, +// Optional: Enable auth token caching for better performance +authTokenCachingStrategy: AuthTokenCachingStrategies.InMemory); +``` + +## Verify Installation + +Test your setup with a simple call: + +```csharp +public class StartupTest +{ + private readonly ITrueLayerClient _client; + + public StartupTest(ITrueLayerClient client) + { + _client = client; + } + + public async Task TestConnection() + { + // Try fetching a payment provider + var response = await _client.PaymentsProviders.GetPaymentsProvider("mock-payments-gb-redirect"); + + if (response.IsSuccessful) + { + Console.WriteLine($"Connected! Provider: {response.Data.DisplayName}"); + } + else + { + Console.WriteLine($"Connection failed: {response.Problem}"); + } + } +} +``` + +## Next Steps + +- [Configure Authentication](authentication.md) +- [Create Your First Payment](payments.md) +- [Handle Errors](error-handling.md) diff --git a/docs/articles/mandates.md b/docs/articles/mandates.md new file mode 100644 index 00000000..1702dcd4 --- /dev/null +++ b/docs/articles/mandates.md @@ -0,0 +1,494 @@ +# Mandates + +Mandates allow you to set up recurring payment authority from a user's bank account. Once authorized, you can create payments against the mandate without requiring the user to re-authenticate each time. + +## Types of Mandates + +TrueLayer supports two types of mandates: + +- **Sweeping** - For variable recurring payments (VRP) +- **Commercial** - For commercial transactions + +## Creating a Mandate + +### Basic Mandate Creation + +```csharp +public async Task CreateMandate() +{ + var mandateRequest = new CreateMandateRequest( + mandateType: MandateType.Sweeping, + providerSelection: new CreateProviderSelection.UserSelected(), + beneficiary: new Beneficiary.MerchantAccount("your-merchant-account-id"), + user: new MandateUserRequest( + name: "John Doe", + email: "john.doe@example.com" + ), + constraints: new Constraints( + maxAmountPerPayment: new AmountConstraint + { + MinimumAmount = 1, + MaximumAmount = 10000 + }, + periodicLimits: new PeriodicLimits + { + Month = new PeriodicLimit + { + MaximumAmount = 50000, + PeriodAlignment = PeriodAlignment.Consent + } + } + ) + ); + + var response = await _client.Mandates.CreateMandate( + mandateRequest, + idempotencyKey: Guid.NewGuid().ToString() + ); + + if (!response.IsSuccessful) + { + throw new Exception($"Mandate creation failed: {response.Problem}"); + } + + // Redirect user to authorization page + var redirectUrl = _client.Mandates.CreateHostedPaymentPageLink( + response.Data.Id, + response.Data.ResourceToken, + new Uri("https://yourdomain.com/mandate/callback") + ); + + return redirectUrl; +} +``` + +## Retrieving a Mandate + +```csharp +public async Task GetMandate(string mandateId) +{ + var response = await _client.Mandates.GetMandate( + mandateId, + MandateType.Sweeping + ); + + if (!response.IsSuccessful) + { + throw new Exception($"Failed to retrieve mandate: {response.Problem}"); + } + + return response.Data.Match( + authRequired => (MandateDetail)authRequired, + authorizing => authorizing, + authorized => authorized, + failed => throw new Exception($"Mandate failed: {failed.FailureReason}"), + revoked => throw new Exception("Mandate has been revoked") + ); +} +``` + +## Listing User Mandates + +```csharp +public async Task> ListUserMandates(string userId) +{ + var query = new ListMandatesQuery + { + UserId = userId, + Limit = 20 + }; + + var response = await _client.Mandates.ListMandates( + query, + MandateType.Sweeping + ); + + if (!response.IsSuccessful) + { + throw new Exception($"Failed to list mandates: {response.Problem}"); + } + + return response.Data.Items.ToList(); +} +``` + +## Custom Authorization Flow + +For more control over the authorization process: + +```csharp +public async Task StartMandateAuthFlow(string mandateId) +{ + var request = new StartAuthorizationFlowRequest + { + ProviderSelection = new ProviderSelection.UserSelected + { + ProviderId = "mock-payments-gb-redirect", + SchemeId = "faster_payments_service" + }, + Redirect = new Redirect + { + ReturnUri = new Uri("https://yourdomain.com/callback"), + DirectReturnUri = new Uri("https://yourdomain.com/direct-callback") + } + }; + + var response = await _client.Mandates.StartAuthorizationFlow( + mandateId, + request, + idempotencyKey: Guid.NewGuid().ToString(), + MandateType.Sweeping + ); + + return response.Data.Match( + authorizing => authorizing.AuthorizationFlow.Actions.Next.Uri.ToString(), + failed => throw new Exception($"Authorization failed: {failed.FailureReason}") + ); +} +``` + +## Confirmation of Funds + +Check if sufficient funds are available before creating a payment: + +```csharp +public async Task CheckFunds(string mandateId, int amountInMinor) +{ + var response = await _client.Mandates.GetConfirmationOfFunds( + mandateId, + amountInMinor, + "GBP", + MandateType.Sweeping + ); + + if (!response.IsSuccessful) + { + throw new Exception($"CoF check failed: {response.Problem}"); + } + + return response.Data.Confirmed; +} +``` + +## Getting Mandate Constraints + +Retrieve the constraints applied to a mandate: + +```csharp +public async Task GetConstraints(string mandateId) +{ + var response = await _client.Mandates.GetMandateConstraints( + mandateId, + MandateType.Sweeping + ); + + if (!response.IsSuccessful) + { + throw new Exception($"Failed to get constraints: {response.Problem}"); + } + + return response.Data.Constraints; +} +``` + +## Revoking a Mandate + +Cancel a mandate when no longer needed: + +```csharp +public async Task RevokeMandate(string mandateId) +{ + var response = await _client.Mandates.RevokeMandate( + mandateId, + idempotencyKey: Guid.NewGuid().ToString(), + MandateType.Sweeping + ); + + if (!response.IsSuccessful) + { + throw new Exception($"Failed to revoke mandate: {response.Problem}"); + } +} +``` + +## Mandate Status + +Mandates transition through various statuses during their lifecycle. Understanding these statuses helps you track mandate progress and handle different scenarios appropriately. + +For complete details, see the [TrueLayer Mandate Status documentation](https://docs.truelayer.com/docs/mandate-statuses). + +### Status Overview + +| Status | Description | Terminal | Notes | +|--------|-------------|----------|-------| +| `authorization_required` | Mandate created but no further action taken | No | User needs to authorize the mandate | +| `authorizing` | User has started but not completed authorization journey | No | Wait for webhook notification | +| `authorized` | User has successfully completed authorization flow | No | Mandate is active and can be used for payments | +| `revoked` | Mandate has been cancelled | Yes | Can be revoked by client or user's bank | +| `failed` | Mandate could not be authorized | Yes | Check `FailureReason` for details | + +### Common Failure Reasons + +When a mandate reaches `failed` status, check the `FailureReason` property for details: + +| Failure Reason | Description | +|----------------|-------------| +| `authorization_failed` | User failed to complete authorization | +| `provider_error` | Error with the provider/bank | +| `provider_rejected` | Provider rejected the mandate | +| `internal_server_error` | TrueLayer processing error | +| `invalid_sort_code` | Invalid bank account sort code | +| `invalid_request` | Request validation failed | +| `expired` | Mandate authorization expired | +| `unknown_error` | Unspecified failure reason | + +**Note:** Always handle unexpected failure reasons defensively, as new reasons may be added. + +### Checking Mandate Status + +```csharp +var response = await _client.Mandates.GetMandate(mandateId, MandateType.Sweeping); + +if (response.IsSuccessful) +{ + response.Data.Match( + authRequired => + { + Console.WriteLine($"Status: {authRequired.Status}"); + Console.WriteLine("Action: User needs to authorize the mandate"); + }, + authorizing => + { + Console.WriteLine($"Status: {authorizing.Status}"); + Console.WriteLine("Action: User is authorizing, wait for completion"); + }, + authorized => + { + Console.WriteLine($"Status: {authorized.Status}"); + Console.WriteLine("Action: Mandate is active and can be used"); + }, + failed => + { + Console.WriteLine($"Status: {failed.Status}"); + Console.WriteLine($"Failure Reason: {failed.FailureReason}"); + Console.WriteLine($"Failed At: {failed.FailedAt}"); + Console.WriteLine($"Failure Stage: {failed.FailureStage}"); + Console.WriteLine("Terminal: Mandate failed"); + }, + revoked => + { + Console.WriteLine($"Status: {revoked.Status}"); + Console.WriteLine($"Revoked At: {revoked.RevokedAt}"); + Console.WriteLine($"Revoked By: {revoked.RevokedBy}"); + Console.WriteLine("Terminal: Mandate has been cancelled"); + } + ); +} +``` + +### Handling Terminal Statuses + +```csharp +public bool IsTerminalStatus(GetMandateResponse mandate) +{ + return mandate.Match( + authRequired => false, + authorizing => false, + authorized => false, + failed => true, // Terminal - mandate failed + revoked => true // Terminal - mandate cancelled + ); +} + +public async Task WaitForAuthorization(string mandateId, TimeSpan timeout) +{ + var startTime = DateTime.UtcNow; + + while (DateTime.UtcNow - startTime < timeout) + { + var response = await _client.Mandates.GetMandate(mandateId, MandateType.Sweeping); + + if (!response.IsSuccessful) + { + throw new Exception($"Failed to get mandate status: {response.Problem?.Detail}"); + } + + var isComplete = response.Data.Match( + authRequired => false, + authorizing => false, + authorized => true, // Success + failed => throw new Exception($"Mandate failed: {failed.FailureReason}"), + revoked => throw new Exception("Mandate was revoked") + ); + + if (isComplete) + { + return; // Mandate authorized successfully + } + + await Task.Delay(TimeSpan.FromSeconds(5)); + } + + throw new TimeoutException("Mandate did not complete authorization within timeout"); +} +``` + +### Handling Specific Failure Reasons + +```csharp +public async Task HandleMandateFailure(string mandateId) +{ + var response = await _client.Mandates.GetMandate(mandateId, MandateType.Sweeping); + + if (!response.IsSuccessful) + { + return MandateResult.Error("Failed to retrieve mandate status"); + } + + return response.Data.Match( + authRequired => MandateResult.RequiresAuth(), + authorizing => MandateResult.Authorizing(), + authorized => MandateResult.Success(), + failed => failed.FailureReason switch + { + "authorization_failed" => MandateResult.Error( + "User failed to complete authorization. Please create a new mandate." + ), + "provider_rejected" => MandateResult.Error( + "Provider rejected the mandate. The bank may not support this operation." + ), + "expired" => MandateResult.Error( + "Mandate authorization expired. Please create a new mandate." + ), + "invalid_sort_code" => MandateResult.Error( + "Invalid bank account details. Please verify and create new mandate." + ), + _ => MandateResult.Error($"Mandate failed: {failed.FailureReason}") + }, + revoked => MandateResult.Error( + $"Mandate was revoked by {revoked.RevokedBy} on {revoked.RevokedAt}" + ) + ); +} +``` + +## Mandate Constraints + +Configure limits on payments made under the mandate: + +### Amount Constraints + +```csharp +var constraints = new Constraints( + maxAmountPerPayment: new AmountConstraint + { + MinimumAmount = 100, // Min 1.00 GBP + MaximumAmount = 100000 // Max 1,000.00 GBP + } +); +``` + +### Periodic Limits + +```csharp +var constraints = new Constraints( + periodicLimits: new PeriodicLimits + { + Day = new PeriodicLimit + { + MaximumAmount = 10000, + PeriodAlignment = PeriodAlignment.Consent + }, + Month = new PeriodicLimit + { + MaximumAmount = 50000, + PeriodAlignment = PeriodAlignment.Calendar + } + } +); +``` + +### Valid Payment Types + +Restrict which payment types can be used: + +```csharp +var constraints = new Constraints( + validPaymentTypes: new[] { "domestic_payment", "international_payment" } +); +``` + +## Best Practices + +### 1. Store Mandate IDs Securely + +Store mandate IDs with user records to enable future payments: + +```csharp +public class User +{ + public string Id { get; set; } + public string MandateId { get; set; } + public DateTime MandateAuthorizedAt { get; set; } +} +``` + +### 2. Check Mandate Status Before Payment + +Always verify mandate is in `authorized` status: + +```csharp +var response = await _client.Mandates.GetMandate(mandateId, MandateType.Sweeping); + +if (!response.IsSuccessful) +{ + throw new Exception("Failed to retrieve mandate"); +} + +var isAuthorized = response.Data.Match( + authRequired => false, + authorizing => false, + authorized => true, + failed => false, + revoked => false +); + +if (!isAuthorized) +{ + throw new InvalidOperationException("Mandate is not authorized"); +} +``` + +### 3. Use Confirmation of Funds + +For large payments, check funds are available: + +```csharp +var fundsAvailable = await CheckFunds(mandateId, paymentAmount); +if (!fundsAvailable) +{ + // Handle insufficient funds +} +``` + +### 4. Handle Revocations + +Users can revoke mandates from their bank. Handle this gracefully: + +```csharp +try +{ + var mandate = await GetMandate(mandateId); +} +catch (Exception ex) when (ex.Message.Contains("revoked")) +{ + // Remove mandate from user record + // Notify user to set up new mandate +} +``` + +## Next Steps + +- [Payments Guide](payments.md) +- [Handle Errors](error-handling.md) +- [API Reference](xref:TrueLayer.Mandates.IMandatesApi) diff --git a/docs/articles/merchant-accounts.md b/docs/articles/merchant-accounts.md new file mode 100644 index 00000000..f4af2d25 --- /dev/null +++ b/docs/articles/merchant-accounts.md @@ -0,0 +1,714 @@ +# Merchant Accounts + +Manage your TrueLayer merchant accounts for receiving payments and processing payouts. Merchant accounts are the destination accounts where funds from payments are settled. + +## Understanding Merchant Accounts + +Merchant accounts are TrueLayer-managed accounts that: +- **Receive payments**: Funds from customer payments are settled here +- **Source payouts**: Transfer funds to beneficiaries (sellers, suppliers, refunds) +- **Multi-currency**: Support different currencies (GBP, EUR, etc.) +- **Account identifiers**: Have sort codes, account numbers, or IBANs for transfers + +## Listing Merchant Accounts + +Retrieve all your merchant accounts: + +```csharp +var response = await _client.MerchantAccounts.ListMerchantAccounts(); + +if (response.IsSuccessful) +{ + foreach (var account in response.Data.Items) + { + Console.WriteLine($"Account ID: {account.Id}"); + Console.WriteLine($"Currency: {account.Currency}"); + Console.WriteLine($"Available Balance: {account.AvailableBalanceInMinor / 100.0:C}"); + Console.WriteLine($"Current Balance: {account.CurrentBalanceInMinor / 100.0:C}"); + + // Account identifiers + foreach (var identifier in account.AccountIdentifiers) + { + identifier.Match( + sortCode => Console.WriteLine($" Sort Code: {sortCode.SortCode}, Account: {sortCode.AccountNumber}"), + iban => Console.WriteLine($" IBAN: {iban.Iban}"), + scan => Console.WriteLine($" SCAN: {scan.Scan}") + ); + } + } +} +``` + +## Getting Account Details + +Get detailed information about a specific merchant account: + +```csharp +var response = await _client.MerchantAccounts.GetMerchantAccount(accountId); + +if (response.IsSuccessful) +{ + var account = response.Data; + + Console.WriteLine($"Account ID: {account.Id}"); + Console.WriteLine($"Currency: {account.Currency}"); + Console.WriteLine($"Available Balance: {account.AvailableBalanceInMinor}"); + Console.WriteLine($"Current Balance: {account.CurrentBalanceInMinor}"); + + // Display account identifiers + foreach (var identifier in account.AccountIdentifiers) + { + identifier.Match( + sortCode => + { + Console.WriteLine("UK Account:"); + Console.WriteLine($" Sort Code: {sortCode.SortCode}"); + Console.WriteLine($" Account Number: {sortCode.AccountNumber}"); + }, + iban => + { + Console.WriteLine("IBAN Account:"); + Console.WriteLine($" IBAN: {iban.Iban}"); + }, + scan => + { + Console.WriteLine("SCAN Account:"); + Console.WriteLine($" SCAN: {scan.Scan}"); + } + ); + } +} +``` + +## Account Balances + +### Understanding Balance Types + +Merchant accounts have two balance types: + +- **Current Balance**: Total funds in the account, including pending transactions +- **Available Balance**: Funds available for immediate payout (excludes holds, reserves) + +```csharp +public class MerchantAccountBalance +{ + public string AccountId { get; set; } + public string Currency { get; set; } + public long CurrentBalanceInMinor { get; set; } + public long AvailableBalanceInMinor { get; set; } + + public decimal CurrentBalance => CurrentBalanceInMinor / 100m; + public decimal AvailableBalance => AvailableBalanceInMinor / 100m; + public decimal PendingBalance => CurrentBalance - AvailableBalance; +} + +public async Task GetAccountBalance(string accountId) +{ + var response = await _client.MerchantAccounts.GetMerchantAccount(accountId); + + if (!response.IsSuccessful) + { + throw new Exception($"Failed to get account: {response.Problem?.Detail}"); + } + + return new MerchantAccountBalance + { + AccountId = response.Data.Id, + Currency = response.Data.Currency, + CurrentBalanceInMinor = response.Data.CurrentBalanceInMinor, + AvailableBalanceInMinor = response.Data.AvailableBalanceInMinor + }; +} +``` + +### Check Available Funds + +Verify sufficient funds before creating a payout: + +```csharp +public async Task HasSufficientFunds(string accountId, long amountInMinor) +{ + var response = await _client.MerchantAccounts.GetMerchantAccount(accountId); + + if (!response.IsSuccessful) + { + return false; + } + + return response.Data.AvailableBalanceInMinor >= amountInMinor; +} + +// Usage +if (await HasSufficientFunds(merchantAccountId, payoutAmount)) +{ + // Create payout + var payoutResponse = await _client.Payouts.CreatePayout( + payoutRequest, + idempotencyKey: Guid.NewGuid().ToString() + ); +} +else +{ + _logger.LogWarning("Insufficient funds for payout of {Amount}", payoutAmount); +} +``` + +## Multi-Currency Account Management + +### Managing Multiple Currencies + +```csharp +public class CurrencyAccountManager +{ + private readonly ITrueLayerClient _client; + + public CurrencyAccountManager(ITrueLayerClient client) + { + _client = client; + } + + public async Task> GetAccountsByCurrency() + { + var response = await _client.MerchantAccounts.ListMerchantAccounts(); + + if (!response.IsSuccessful) + { + return new Dictionary(); + } + + return response.Data.Items + .GroupBy(a => a.Currency) + .ToDictionary( + g => g.Key, + g => g.First() // Take first account for each currency + ); + } + + public async Task GetAccountIdForCurrency(string currency) + { + var accounts = await GetAccountsByCurrency(); + + if (!accounts.ContainsKey(currency)) + { + throw new Exception($"No merchant account found for currency {currency}"); + } + + return accounts[currency].Id; + } +} + +// Usage +var manager = new CurrencyAccountManager(_client); +var gbpAccountId = await manager.GetAccountIdForCurrency("GBP"); +var eurAccountId = await manager.GetAccountIdForCurrency("EUR"); +``` + +### Currency-Specific Payouts + +```csharp +public async Task CreatePayoutInCurrency( + string currency, + long amountInMinor, + Beneficiary beneficiary) +{ + // Get the merchant account for this currency + var accountId = await GetAccountIdForCurrency(currency); + + // Check available balance + if (!await HasSufficientFunds(accountId, amountInMinor)) + { + throw new InsufficientFundsException( + $"Insufficient {currency} funds for payout" + ); + } + + // Create payout + var request = new CreatePayoutRequest( + merchantAccountId: accountId, + amountInMinor: amountInMinor, + currency: currency, + beneficiary: beneficiary + ); + + var response = await _client.Payouts.CreatePayout( + request, + idempotencyKey: Guid.NewGuid().ToString() + ); + + if (!response.IsSuccessful) + { + throw new Exception($"Payout failed: {response.Problem?.Detail}"); + } + + return response.Data!.Match( + authRequired => authRequired.Id, + created => created.Id + ); +} +``` + +## Account Identifiers + +### Working with Account Identifiers + +Extract and use account identifiers for external transfers: + +```csharp +public class AccountIdentifierHelper +{ + public static (string? SortCode, string? AccountNumber) GetUKDetails( + MerchantAccount account) + { + foreach (var identifier in account.AccountIdentifiers) + { + var result = identifier.Match<(string?, string?)>( + sortCode => (sortCode.SortCode, sortCode.AccountNumber), + iban => (null, null), + scan => (null, null) + ); + + if (result.Item1 != null) + { + return result; + } + } + + return (null, null); + } + + public static string? GetIBAN(MerchantAccount account) + { + foreach (var identifier in account.AccountIdentifiers) + { + var iban = identifier.Match( + sortCode => null, + iban => iban.Iban, + scan => null + ); + + if (iban != null) + { + return iban; + } + } + + return null; + } + + public static string GetFormattedIdentifier(MerchantAccount account) + { + foreach (var identifier in account.AccountIdentifiers) + { + return identifier.Match( + sortCode => $"{sortCode.SortCode} {sortCode.AccountNumber}", + iban => iban.Iban, + scan => scan.Scan + ); + } + + return "No identifier available"; + } +} +``` + +## Account Information Display + +### Display Account Summary + +```csharp +public class MerchantAccountViewModel +{ + public string Id { get; set; } + public string Currency { get; set; } + public decimal CurrentBalance { get; set; } + public decimal AvailableBalance { get; set; } + public string FormattedIdentifier { get; set; } + public string AccountType { get; set; } // "UK", "IBAN", or "SCAN" +} + +public async Task> GetAccountsForDisplay() +{ + var response = await _client.MerchantAccounts.ListMerchantAccounts(); + + if (!response.IsSuccessful) + { + return new List(); + } + + return response.Data.Items + .Select(account => new MerchantAccountViewModel + { + Id = account.Id, + Currency = account.Currency, + CurrentBalance = account.CurrentBalanceInMinor / 100m, + AvailableBalance = account.AvailableBalanceInMinor / 100m, + FormattedIdentifier = AccountIdentifierHelper.GetFormattedIdentifier(account), + AccountType = DetermineAccountType(account) + }) + .ToList(); +} + +private string DetermineAccountType(MerchantAccount account) +{ + foreach (var identifier in account.AccountIdentifiers) + { + return identifier.Match( + sortCode => "UK", + iban => "IBAN", + scan => "SCAN" + ); + } + + return "Unknown"; +} +``` + +## Payment Receiving + +### Using Merchant Accounts as Payment Beneficiaries + +Direct payments to your merchant account: + +```csharp +public async Task CreatePaymentToMerchantAccount( + string merchantAccountId, + int amountInMinor, + PaymentUserRequest user) +{ + // Get account details for display name + var accountResponse = await _client.MerchantAccounts.GetMerchantAccount(merchantAccountId); + + if (!accountResponse.IsSuccessful) + { + throw new Exception("Merchant account not found"); + } + + var account = accountResponse.Data; + + var request = new CreatePaymentRequest( + amountInMinor: amountInMinor, + currency: account.Currency, + paymentMethod: new CreatePaymentMethod.BankTransfer( + new CreateProviderSelection.UserSelected(), + new Beneficiary.MerchantAccount( + merchantAccountId: merchantAccountId, + accountHolderName: "Your Business Ltd" + ) + ), + user: user + ); + + var response = await _client.Payments.CreatePayment( + request, + idempotencyKey: Guid.NewGuid().ToString() + ); + + if (!response.IsSuccessful) + { + throw new Exception($"Payment creation failed: {response.Problem?.Detail}"); + } + + return response.Data!.Match( + authRequired => authRequired.Id, + authorized => authorized.Id, + failed => throw new Exception($"Payment failed: {failed.FailureReason}"), + authorizing => authorizing.Id + ); +} +``` + +## Best Practices + +### 1. Cache Account Information + +Merchant account details rarely change - cache them: + +```csharp +public class MerchantAccountCache +{ + private readonly ITrueLayerClient _client; + private readonly IMemoryCache _cache; + private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(1); + + public async Task GetAccount(string accountId) + { + var cacheKey = $"merchant_account_{accountId}"; + + if (_cache.TryGetValue(cacheKey, out MerchantAccount cached)) + { + return cached; + } + + var response = await _client.MerchantAccounts.GetMerchantAccount(accountId); + + if (!response.IsSuccessful) + { + return null; + } + + _cache.Set(cacheKey, response.Data, CacheDuration); + return response.Data; + } + + public async Task> GetAllAccounts() + { + const string cacheKey = "merchant_accounts_all"; + + if (_cache.TryGetValue(cacheKey, out List cached)) + { + return cached; + } + + var response = await _client.MerchantAccounts.ListMerchantAccounts(); + + if (!response.IsSuccessful) + { + return new List(); + } + + var accounts = response.Data.Items.ToList(); + _cache.Set(cacheKey, accounts, CacheDuration); + + return accounts; + } +} +``` + +### 2. Always Check Available Balance + +Don't rely on current balance - use available balance: + +```csharp +public async Task CanProcessPayout(string accountId, long amountInMinor) +{ + var response = await _client.MerchantAccounts.GetMerchantAccount(accountId); + + if (!response.IsSuccessful) + { + return false; + } + + // Use AvailableBalanceInMinor, not CurrentBalanceInMinor + return response.Data.AvailableBalanceInMinor >= amountInMinor; +} +``` + +### 3. Handle Multiple Accounts per Currency + +Some merchants may have multiple accounts for the same currency: + +```csharp +public async Task> GetAccountsForCurrency(string currency) +{ + var response = await _client.MerchantAccounts.ListMerchantAccounts(); + + if (!response.IsSuccessful) + { + return new List(); + } + + return response.Data.Items + .Where(a => a.Currency == currency) + .ToList(); +} +``` + +### 4. Log Balance Changes + +Track balance changes for reconciliation: + +```csharp +public class BalanceChangeLogger +{ + private readonly ITrueLayerClient _client; + private readonly ILogger _logger; + + public async Task LogBalanceChange(string accountId, string operation) + { + var response = await _client.MerchantAccounts.GetMerchantAccount(accountId); + + if (response.IsSuccessful) + { + _logger.LogInformation( + "Balance after {Operation}: Account {AccountId}, Available: {Available}, Current: {Current}", + operation, + accountId, + response.Data.AvailableBalanceInMinor, + response.Data.CurrentBalanceInMinor + ); + } + } +} + +// Usage +await _balanceLogger.LogBalanceChange(accountId, "payout_created"); +``` + +### 5. Store Account Configuration + +Store account IDs in configuration: + +```csharp +public class MerchantAccountConfiguration +{ + public string PrimaryGBPAccountId { get; set; } + public string PrimaryEURAccountId { get; set; } + public Dictionary CurrencyAccounts { get; set; } +} + +// In appsettings.json +{ + "MerchantAccounts": { + "PrimaryGBPAccountId": "your-gbp-account-id", + "PrimaryEURAccountId": "your-eur-account-id", + "CurrencyAccounts": { + "GBP": "your-gbp-account-id", + "EUR": "your-eur-account-id" + } + } +} +``` + +## Common Scenarios + +### Account Selection for Payout + +```csharp +public async Task ProcessPayoutWithAccountSelection( + long amountInMinor, + string currency, + Beneficiary beneficiary) +{ + // Get all accounts for the currency + var accounts = await GetAccountsForCurrency(currency); + + if (!accounts.Any()) + { + throw new Exception($"No {currency} merchant account available"); + } + + // Find account with sufficient balance + MerchantAccount? selectedAccount = null; + + foreach (var account in accounts) + { + if (account.AvailableBalanceInMinor >= amountInMinor) + { + selectedAccount = account; + break; + } + } + + if (selectedAccount == null) + { + throw new InsufficientFundsException( + $"Insufficient {currency} funds across all accounts" + ); + } + + // Create payout with selected account + var request = new CreatePayoutRequest( + merchantAccountId: selectedAccount.Id, + amountInMinor: amountInMinor, + currency: currency, + beneficiary: beneficiary + ); + + var response = await _client.Payouts.CreatePayout( + request, + idempotencyKey: Guid.NewGuid().ToString() + ); + + return response.Data!.Match( + authRequired => authRequired.Id, + created => created.Id + ); +} +``` + +### Account Balance Dashboard + +```csharp +public class AccountDashboard +{ + public class AccountSummary + { + public string Currency { get; set; } + public decimal TotalAvailable { get; set; } + public decimal TotalCurrent { get; set; } + public int AccountCount { get; set; } + public List Accounts { get; set; } + } + + public async Task> GetDashboardData() + { + var response = await _client.MerchantAccounts.ListMerchantAccounts(); + + if (!response.IsSuccessful) + { + return new List(); + } + + return response.Data.Items + .GroupBy(a => a.Currency) + .Select(g => new AccountSummary + { + Currency = g.Key, + TotalAvailable = g.Sum(a => a.AvailableBalanceInMinor) / 100m, + TotalCurrent = g.Sum(a => a.CurrentBalanceInMinor) / 100m, + AccountCount = g.Count(), + Accounts = g.ToList() + }) + .ToList(); + } +} +``` + +### Balance Monitoring + +```csharp +public class BalanceMonitor +{ + private readonly ITrueLayerClient _client; + private readonly ILogger _logger; + + public async Task CheckLowBalances(decimal thresholdPercentage = 0.1m) + { + var response = await _client.MerchantAccounts.ListMerchantAccounts(); + + if (!response.IsSuccessful) + { + return; + } + + foreach (var account in response.Data.Items) + { + var availableBalance = account.AvailableBalanceInMinor; + var currentBalance = account.CurrentBalanceInMinor; + + if (currentBalance == 0) continue; + + var availablePercentage = (decimal)availableBalance / currentBalance; + + if (availablePercentage < thresholdPercentage) + { + _logger.LogWarning( + "Low available balance: Account {AccountId} ({Currency}), Available: {Available}, Current: {Current}", + account.Id, + account.Currency, + availableBalance / 100m, + currentBalance / 100m + ); + + // Send alert, notification, etc. + } + } + } +} +``` + +## See Also + +- [Payouts](payouts.md) - Creating payouts from merchant accounts +- [Payments](payments.md) - Receiving payments to merchant accounts +- [API Reference](xref:TrueLayer.MerchantAccounts.IMerchantAccountsApi) diff --git a/docs/articles/multiple-clients.md b/docs/articles/multiple-clients.md new file mode 100644 index 00000000..10e42928 --- /dev/null +++ b/docs/articles/multiple-clients.md @@ -0,0 +1,42 @@ +# Multiple TrueLayer Clients + +Configure and use multiple TrueLayer clients in the same application. + +## Keyed Services (.NET 8+) + +```csharp +services + .AddKeyedTrueLayer("GBP", configuration, options => + { + options.ClientId = "gbp-client-id"; + options.ClientSecret = "gbp-secret"; + }) + .AddKeyedTrueLayer("EUR", configuration, options => + { + options.ClientId = "eur-client-id"; + options.ClientSecret = "eur-secret"; + }); +``` + +## Dependency Injection + +```csharp +public class PaymentService +{ + private readonly ITrueLayerClient _gbpClient; + private readonly ITrueLayerClient _eurClient; + + public PaymentService( + [FromKeyedServices("GBP")] ITrueLayerClient gbpClient, + [FromKeyedServices("EUR")] ITrueLayerClient eurClient) + { + _gbpClient = gbpClient; + _eurClient = eurClient; + } +} +``` + +## See Also + +- [Authentication](authentication.md) +- [Configuration](configuration.md) diff --git a/docs/articles/payments.md b/docs/articles/payments.md new file mode 100644 index 00000000..8cc82420 --- /dev/null +++ b/docs/articles/payments.md @@ -0,0 +1,699 @@ +# Payments + +Create and manage payments using the TrueLayer Payments API. Payments allow you to initiate bank transfers from your users' accounts. + +> **See also**: The [MVC Example](https://github.com/TrueLayer/truelayer-dotnet/tree/main/examples/MvcExample) demonstrates a complete payment flow in [PaymentsController.cs](https://github.com/TrueLayer/truelayer-dotnet/blob/main/examples/MvcExample/Controllers/PaymentsController.cs). + +## Basic Payment Creation + +### User-Selected Provider + +Let the user choose their bank: + +```csharp +var request = new CreatePaymentRequest( + amountInMinor: 10000, // £100.00 + currency: Currencies.GBP, + paymentMethod: new CreatePaymentMethod.BankTransfer( + new CreateProviderSelection.UserSelected(), + new Beneficiary.ExternalAccount( + "Merchant Name", + "merchant-reference", + new AccountIdentifier.SortCodeAccountNumber("567890", "12345678") + ) + ), + user: new PaymentUserRequest("John Doe", "john@example.com") +); + +var response = await _client.Payments.CreatePayment( + request, + idempotencyKey: Guid.NewGuid().ToString() +); + +if (response.IsSuccessful) +{ + // Redirect user to authorization + var redirectUrl = response.Data!.Match( + authRequired => authRequired.HostedPage!.Uri.ToString(), + authorized => $"Already authorized: {authorized.Id}", + failed => throw new Exception($"Payment failed: {failed.FailureReason}"), + authorizing => $"Authorizing: {authorizing.Id}" + ); +} +``` + +### Preselected Provider + +Direct users to a specific bank: + +```csharp +var request = new CreatePaymentRequest( + amountInMinor: 5000, + currency: Currencies.GBP, + paymentMethod: new CreatePaymentMethod.BankTransfer( + new CreateProviderSelection.Preselected( + providerId: "mock-payments-gb-redirect", + schemeSelection: new SchemeSelection.Preselected + { + SchemeId = "faster_payments_service" + } + ), + new Beneficiary.ExternalAccount( + "TrueLayer", + "payment-ref-123", + new AccountIdentifier.SortCodeAccountNumber("567890", "12345678") + ) + ), + user: new PaymentUserRequest("Jane Doe", "jane@example.com") +); +``` + +## Provider Filtering + +Filter available banks for the user: + +```csharp +var request = new CreatePaymentRequest( + amountInMinor: 10000, + currency: Currencies.GBP, + paymentMethod: new CreatePaymentMethod.BankTransfer( + new CreateProviderSelection.UserSelected + { + Filter = new ProviderFilter + { + ProviderIds = new[] + { + "lloyds-bank", + "hsbc", + "barclays" + } + } + }, + beneficiary + ), + user: userRequest +); +``` + +## Beneficiary Types + +### External Account + +Pay to an external bank account using different account identifier types: + +#### Sort Code & Account Number + +```csharp +var beneficiary = new Beneficiary.ExternalAccount( + accountHolderName: "ACME Ltd", + reference: "INV-2024-001", + accountIdentifier: new AccountIdentifier.SortCodeAccountNumber("207106", "44377677") +); +``` + +#### IBAN + +```csharp +var beneficiary = new Beneficiary.ExternalAccount( + accountHolderName: "John Smith", + reference: "Payment for services", + accountIdentifier: new AccountIdentifier.Iban("GB33BUKB20201555555555") +); +``` + +### Merchant Account + +Pay into your own merchant account: + +```csharp +var beneficiary = new Beneficiary.MerchantAccount( + merchantAccountId: "your-merchant-account-id", + accountHolderName: "Your Business Ltd" +); +``` + +## User Information + +### Basic User Details + +```csharp +var user = new PaymentUserRequest( + name: "John Doe", + email: "john@example.com" +); +``` + +### Complete User Details + +```csharp +var user = new PaymentUserRequest( + name: "John Doe", + email: "john@example.com", + phone: "+447700900000", + dateOfBirth: new DateTime(1990, 1, 1), + address: new Address( + city: "London", + state: "England", + zip: "EC1R 4RB", + countryCode: "GB", + addressLine1: "1 Hardwick St", + addressLine2: "Clerkenwell" + ) +); +``` + +## Hosted Payment Page + +### Recommended Approach + +The recommended way to use TrueLayer's Hosted Payment Page is to include `HostedPageRequest` when creating the payment. This ensures the HPP URL is generated with the payment and returned in the response. + +> **See also**: The [MVC Example](https://github.com/TrueLayer/truelayer-dotnet/tree/main/examples/MvcExample) uses `HostedPageRequest` in [PaymentsController.cs](https://github.com/TrueLayer/truelayer-dotnet/blob/main/examples/MvcExample/Controllers/PaymentsController.cs#L51-L69). + +```csharp +var hostedPage = new HostedPageRequest( + returnUri: new Uri("https://yourdomain.com/payment/callback"), + countryCode: "GB", + languageCode: "en" +); + +var request = new CreatePaymentRequest( + amountInMinor: 10000, + currency: Currencies.GBP, + paymentMethod: paymentMethod, + user: user, + hostedPage: hostedPage +); + +var response = await _client.Payments.CreatePayment( + request, + idempotencyKey: Guid.NewGuid().ToString() +); + +if (response.IsSuccessful) +{ + // HPP URL is included in the response when HostedPageRequest is used + var hppUrl = response.Data!.Match( + authRequired => authRequired.HostedPage!.Uri.ToString(), + authorized => throw new Exception("Payment already authorized"), + failed => throw new Exception($"Payment failed: {failed.FailureReason}"), + authorizing => throw new Exception("Payment is authorizing") + ); + + return Redirect(hppUrl); +} +``` + +### Complete Example with Error Handling + +```csharp +public async Task CreatePaymentWithHostedPage(Order order) +{ + var request = new CreatePaymentRequest( + amountInMinor: order.TotalInMinor, + currency: Currencies.GBP, + paymentMethod: new CreatePaymentMethod.BankTransfer( + new CreateProviderSelection.UserSelected(), + new Beneficiary.MerchantAccount(merchantAccountId) + ), + user: new PaymentUserRequest(order.CustomerName, order.CustomerEmail), + hostedPage: new HostedPageRequest( + returnUri: new Uri($"https://yourdomain.com/orders/{order.Id}/payment-return"), + countryCode: "GB", + languageCode: "en" + ) + ); + + var response = await _client.Payments.CreatePayment( + request, + idempotencyKey: order.PaymentIdempotencyKey + ); + + if (!response.IsSuccessful) + { + _logger.LogError( + "Payment creation failed: {TraceId}", + response.TraceId + ); + return View("PaymentError"); + } + + // Store payment ID with order + order.PaymentId = response.Data!.Id; + await _orderRepository.UpdateAsync(order); + + // Redirect to TrueLayer's Hosted Payment Page + var hppUrl = response.Data!.Match( + authRequired => authRequired.HostedPage!.Uri.ToString(), + _ => throw new Exception("Unexpected payment state") + ); + + return Redirect(hppUrl); +} +``` + +## Payment Status + +Payments transition through various statuses as they progress. Understanding these statuses helps you track payment progress and handle different scenarios appropriately. + +For complete details, see the [TrueLayer Payment Status documentation](https://docs.truelayer.com/docs/payment-statuses). + +> **See also**: The [MVC Example](https://github.com/TrueLayer/truelayer-dotnet/tree/main/examples/MvcExample) demonstrates handling all payment statuses in [PaymentsController.cs](https://github.com/TrueLayer/truelayer-dotnet/blob/main/examples/MvcExample/Controllers/PaymentsController.cs#L114-L161). + +### Status Overview + +| Status | Description | Terminal | Notes | +|--------|-------------|----------|-------| +| `authorization_required` | Payment created successfully but no further action taken | No | Redirect user to Hosted Payment Page | +| `authorizing` | User started but hasn't completed authorization | No | Wait for webhook notification | +| `authorized` | User completed authorization; payment submitted to bank | No | No further user action needed | +| `executed` | Payment successfully submitted to bank | Yes* | Terminal for external account payments | +| `settled` | Payment settled into merchant account | Yes* | Terminal for merchant account payments | +| `failed` | Payment failed to progress | Yes | Check `FailureReason` for details | + +**Terminal Status Notes:** +- For **merchant account** payments: `settled` or `failed` are terminal +- For **external account** payments: `executed` or `failed` are terminal + +### Common Failure Reasons + +When a payment reaches `failed` status, check the `FailureReason` property for details: + +| Failure Reason | Description | +|----------------|-------------| +| `insufficient_funds` | User's account has insufficient funds | +| `canceled` | Payment was canceled by user or merchant | +| `expired` | Payment authorization expired | +| `provider_rejected` | Bank/provider rejected the payment | +| `invalid_account_details` | Beneficiary account details are invalid | + +**Note:** Always handle unexpected failure reasons defensively, as new reasons may be added. + +### Checking Payment Status + +```csharp +var response = await _client.Payments.GetPayment(paymentId); + +if (response.IsSuccessful) +{ + response.Data.Match( + authRequired => + { + Console.WriteLine($"Status: {authRequired.Status}"); + Console.WriteLine("Action: Redirect user to Hosted Payment Page"); + }, + authorizing => + { + Console.WriteLine($"Status: {authorizing.Status}"); + Console.WriteLine("Action: Wait for authorization to complete"); + }, + authorized => + { + Console.WriteLine($"Status: {authorized.Status}"); + Console.WriteLine("Action: Payment submitted to bank, waiting for execution"); + }, + executed => + { + Console.WriteLine($"Status: {executed.Status}"); + Console.WriteLine("Terminal: Payment completed (external account)"); + }, + settled => + { + Console.WriteLine($"Status: {settled.Status}"); + Console.WriteLine("Terminal: Payment completed (merchant account)"); + }, + failed => + { + Console.WriteLine($"Status: {failed.Status}"); + Console.WriteLine($"Failure Reason: {failed.FailureReason}"); + Console.WriteLine("Terminal: Payment failed"); + }, + attemptFailed => + { + Console.WriteLine($"Status: {attemptFailed.Status}"); + Console.WriteLine($"Failure Reason: {attemptFailed.FailureReason}"); + Console.WriteLine("Action: May retry automatically"); + } + ); +} +``` + +### Handling Terminal Statuses + +```csharp +public bool IsTerminalStatus(GetPaymentResponse payment) +{ + return payment.Match( + authRequired => false, + authorizing => false, + authorized => false, + executed => true, // Terminal for external accounts + settled => true, // Terminal for merchant accounts + failed => true, + attemptFailed => false // May retry + ); +} + +public async Task WaitForTerminalStatus(string paymentId, TimeSpan timeout) +{ + var startTime = DateTime.UtcNow; + + while (DateTime.UtcNow - startTime < timeout) + { + var response = await _client.Payments.GetPayment(paymentId); + + if (!response.IsSuccessful) + { + throw new Exception($"Failed to get payment status: {response.Problem?.Detail}"); + } + + if (IsTerminalStatus(response.Data)) + { + return; // Payment reached terminal status + } + + await Task.Delay(TimeSpan.FromSeconds(5)); + } + + throw new TimeoutException("Payment did not reach terminal status within timeout"); +} +``` + +## Payment Refunds + +### Full Refund + +```csharp +var refundRequest = new CreatePaymentRefundRequest( + amountInMinor: 10000, // Full amount + reference: "Full refund for order #12345" +); + +var response = await _client.Payments.CreatePaymentRefund( + paymentId, + idempotencyKey: Guid.NewGuid().ToString(), + refundRequest +); + +if (response.IsSuccessful) +{ + Console.WriteLine($"Refund ID: {response.Data.Id}"); +} +``` + +### Partial Refund + +```csharp +var refundRequest = new CreatePaymentRefundRequest( + amountInMinor: 5000, // Partial amount + reference: "Partial refund - damaged item" +); +``` + +### List All Refunds + +```csharp +var response = await _client.Payments.ListPaymentRefunds(paymentId); + +if (response.IsSuccessful) +{ + foreach (var refund in response.Data.Items) + { + refund.Match( + pending => Console.WriteLine($"Pending: {pending.Id}"), + authorized => Console.WriteLine($"Authorized: {authorized.Id}"), + executed => Console.WriteLine($"Executed: {executed.Id}"), + failed => Console.WriteLine($"Failed: {failed.FailureReason}") + ); + } +} +``` + +### Get Refund Status + +```csharp +var response = await _client.Payments.GetPaymentRefund(paymentId, refundId); + +if (response.IsSuccessful) +{ + var status = response.Data.Match( + pending => "Refund pending", + authorized => "Refund authorized", + executed => "Refund completed", + failed => $"Refund failed: {failed.FailureReason}" + ); +} +``` + +## Cancelling Payments + +Cancel a payment before it's executed: + +```csharp +var response = await _client.Payments.CancelPayment( + paymentId, + idempotencyKey: Guid.NewGuid().ToString() +); + +if (response.IsSuccessful) +{ + Console.WriteLine("Payment cancelled successfully"); +} +else +{ + // Payment may have already been executed + Console.WriteLine($"Cancellation failed: {response.Problem}"); +} +``` + +## Custom Authorization Flow + +For more control over the authorization process: + +```csharp +var authRequest = new StartAuthorizationFlowRequest +{ + ProviderSelection = new ProviderSelection.UserSelected + { + ProviderId = "lloyds-bank", + SchemeId = "faster_payments_service" + }, + Redirect = new Redirect + { + ReturnUri = new Uri("https://yourdomain.com/callback"), + DirectReturnUri = new Uri("https://yourdomain.com/direct-callback") + } +}; + +var response = await _client.Payments.StartAuthorizationFlow( + paymentId, + idempotencyKey: Guid.NewGuid().ToString(), + authRequest +); + +if (response.IsSuccessful) +{ + var redirectUrl = response.Data.Match( + authorizing => authorizing.AuthorizationFlow.Actions.Next.Uri.ToString(), + failed => throw new Exception($"Authorization failed: {failed.FailureReason}") + ); + + return Redirect(redirectUrl); +} +``` + +## Idempotency + +Always use idempotency keys to prevent duplicate payments: + +```csharp +// Generate once per operation +var idempotencyKey = Guid.NewGuid().ToString(); + +// Safe to retry with same key +var response = await _client.Payments.CreatePayment(request, idempotencyKey); +if (!response.IsSuccessful) +{ + // Retry with SAME key - won't create duplicate + response = await _client.Payments.CreatePayment(request, idempotencyKey); +} +``` + +## Best Practices + +### 1. Always Use Idempotency Keys + +```csharp +// Store with your order/transaction +public class PaymentTransaction +{ + public string OrderId { get; set; } + public string IdempotencyKey { get; set; } = Guid.NewGuid().ToString(); + public string PaymentId { get; set; } +} +``` + +### 2. Handle All Payment Statuses + +```csharp +public async Task CheckPaymentStatus(string paymentId) +{ + var response = await _client.Payments.GetPayment(paymentId); + + if (!response.IsSuccessful) + { + _logger.LogError("Failed to get payment: {TraceId}", response.TraceId); + throw new Exception("Payment lookup failed"); + } + + return response.Data.Match( + authRequired => PaymentStatus.AwaitingAuth, + authorizing => PaymentStatus.Authorizing, + authorized => PaymentStatus.Authorized, + executed => PaymentStatus.Executed, + settled => PaymentStatus.Settled, + failed => PaymentStatus.Failed, + attemptFailed => PaymentStatus.AttemptFailed + ); +} +``` + +### 3. Use Webhooks for Status Updates + +Don't poll - use webhooks to get notified of status changes: + +```csharp +[HttpPost("webhooks/payments")] +public async Task HandlePaymentWebhook([FromBody] WebhookPayload payload) +{ + // Verify webhook signature + // Process payment status change + var paymentId = payload.PaymentId; + var newStatus = payload.Status; + + await UpdatePaymentStatus(paymentId, newStatus); + + return Ok(); +} +``` + +### 4. Store Payment Details + +```csharp +public class PaymentRecord +{ + public string PaymentId { get; set; } + public string IdempotencyKey { get; set; } + public int AmountInMinor { get; set; } + public string Currency { get; set; } + public string Status { get; set; } + public DateTime CreatedAt { get; set; } + public string UserId { get; set; } +} +``` + +### 5. Handle Failures Gracefully + +```csharp +if (!response.IsSuccessful) +{ + _logger.LogError( + "Payment creation failed. TraceId: {TraceId}, Status: {Status}, Error: {Error}", + response.TraceId, + response.StatusCode, + response.Problem?.Detail + ); + + // Present user-friendly message + return View("PaymentError", new ErrorModel + { + Message = "Payment could not be initiated. Please try again.", + SupportReference = response.TraceId + }); +} +``` + +## Common Scenarios + +### E-commerce Checkout + +```csharp +public async Task ProcessCheckoutPayment(Order order) +{ + var request = new CreatePaymentRequest( + amountInMinor: order.TotalInMinor, + currency: Currencies.GBP, + paymentMethod: new CreatePaymentMethod.BankTransfer( + new CreateProviderSelection.UserSelected(), + new Beneficiary.MerchantAccount(merchantAccountId) + ), + user: new PaymentUserRequest(order.CustomerName, order.CustomerEmail), + hostedPage: new HostedPageRequest( + returnUri: new Uri($"https://shop.com/orders/{order.Id}/complete"), + countryCode: "GB", + languageCode: "en" + ) + ); + + var response = await _client.Payments.CreatePayment( + request, + idempotencyKey: order.PaymentIdempotencyKey + ); + + // Store payment ID with order + order.PaymentId = response.Data!.Id; + await _orderRepository.UpdateAsync(order); + + return response.Data!.Match( + authRequired => authRequired.HostedPage!.Uri.ToString(), + _ => throw new Exception("Unexpected payment state") + ); +} +``` + +### Subscription Payment + +```csharp +// Use mandate for recurring payments - see Mandates guide +``` + +### Refund Processing + +```csharp +public async Task ProcessRefund(string paymentId, int amountInMinor, string reason) +{ + var refundRequest = new CreatePaymentRefundRequest( + amountInMinor: amountInMinor, + reference: $"Refund: {reason}" + ); + + var response = await _client.Payments.CreatePaymentRefund( + paymentId, + idempotencyKey: Guid.NewGuid().ToString(), + refundRequest + ); + + if (response.IsSuccessful) + { + _logger.LogInformation( + "Refund created: {RefundId} for payment {PaymentId}", + response.Data.Id, + paymentId + ); + return true; + } + + _logger.LogError( + "Refund failed for payment {PaymentId}: {Error}", + paymentId, + response.Problem?.Detail + ); + return false; +} +``` + +## See Also + +- [Mandates](mandates.md) - For recurring payments +- [Error Handling](error-handling.md) - Handle payment errors +- [API Reference](xref:TrueLayer.Payments.IPaymentsApi) - Complete API documentation diff --git a/docs/articles/payouts.md b/docs/articles/payouts.md new file mode 100644 index 00000000..93346610 --- /dev/null +++ b/docs/articles/payouts.md @@ -0,0 +1,700 @@ +# Payouts + +Process payouts to send funds from your merchant account to beneficiaries. Payouts are perfect for marketplace disbursements, refunds, and payments to suppliers. + +> **See also**: The [MVC Example](https://github.com/TrueLayer/truelayer-dotnet/tree/main/examples/MvcExample) demonstrates a complete payout flow in [PayoutController.cs](https://github.com/TrueLayer/truelayer-dotnet/blob/main/examples/MvcExample/Controllers/PayoutController.cs). + +## Basic Payout Creation + +### Payout to UK Account + +```csharp +var request = new CreatePayoutRequest( + merchantAccountId: "your-merchant-account-id", + amountInMinor: 10000, // £100.00 + currency: Currencies.GBP, + beneficiary: new Beneficiary.ExternalAccount( + accountHolderName: "John Smith", + reference: "Payment for services - Invoice #123", + accountIdentifier: new AccountIdentifier.SortCodeAccountNumber("20-71-06", "44377677") + ) +); + +var response = await _client.Payouts.CreatePayout( + request, + idempotencyKey: Guid.NewGuid().ToString() +); + +if (response.IsSuccessful) +{ + var payoutId = response.Data!.Match( + authRequired => authRequired.Id, + created => created.Id + ); + + Console.WriteLine($"Payout created: {payoutId}"); +} +``` + +### Payout to IBAN + +For international or SEPA transfers: + +```csharp +var request = new CreatePayoutRequest( + merchantAccountId: merchantAccountId, + amountInMinor: 50000, // £500.00 + currency: Currencies.GBP, + beneficiary: new Beneficiary.ExternalAccount( + accountHolderName: "ACME Corp", + reference: "Invoice payment INV-2024-001", + accountIdentifier: new AccountIdentifier.Iban("GB33BUKB20201555555555") + ) +); +``` + +## Complete Beneficiary Information + +### With Address and Date of Birth + +```csharp +var beneficiary = new Beneficiary.ExternalAccount( + accountHolderName: "Jane Doe", + reference: "Marketplace payout - Order #789", + accountIdentifier: new AccountIdentifier.SortCodeAccountNumber("20-71-06", "12345678"), + dateOfBirth: new DateTime(1985, 6, 15), + address: new Address( + city: "London", + state: "England", + zip: "EC1R 4RB", + countryCode: "GB", + addressLine1: "123 High Street", + addressLine2: "Flat 2" + ) +); + +var request = new CreatePayoutRequest( + merchantAccountId: merchantAccountId, + amountInMinor: 25000, + currency: Currencies.GBP, + beneficiary: beneficiary +); +``` + +## Adding Metadata + +Store custom data with your payout: + +```csharp +var request = new CreatePayoutRequest( + merchantAccountId: merchantAccountId, + amountInMinor: 10000, + currency: Currencies.GBP, + beneficiary: beneficiary, + metadata: new Dictionary + { + { "order_id", "ORD-2024-001" }, + { "seller_id", "SELLER-123" }, + { "marketplace_fee", "500" } + } +); +``` + +## Payout Status + +Payouts transition through various statuses as they are processed. Understanding these statuses helps you track payout progress and handle different scenarios appropriately. + +For complete details, see the [TrueLayer Payout and Refund Status documentation](https://docs.truelayer.com/docs/payout-and-refund-statuses). + +> **See also**: The [MVC Example](https://github.com/TrueLayer/truelayer-dotnet/tree/main/examples/MvcExample) demonstrates handling all payout statuses in [PayoutController.cs](https://github.com/TrueLayer/truelayer-dotnet/blob/main/examples/MvcExample/Controllers/PayoutController.cs#L90-L127). + +### Status Overview + +| Status | Description | Terminal | Notes | +|--------|-------------|----------|-------| +| `authorization_required` | Payout created but requires additional authorization | No | Complete authorization flow | +| `pending` | Payout created but not yet authorized or sent to payment scheme | No | Waiting for authorization | +| `authorized` | Sent to payment scheme for execution | No | Processing by payment scheme | +| `executed` | Payout amount deducted from merchant account | Yes | Complete - sent to beneficiary* | +| `failed` | Payout did not complete, amount not deducted | Yes | Check `FailureReason` for details | + +**Note:** `executed` means the amount has been deducted from your merchant account and sent to the payment scheme, but is not a guarantee that the beneficiary has received the funds. + +### Common Failure Reasons + +When a payout reaches `failed` status, check the `FailureReason` property for details: + +| Failure Reason | Description | +|----------------|-------------| +| `blocked` | Blocked by regulatory requirements | +| `insufficient_funds` | Not enough money in merchant account | +| `invalid_iban` | Invalid beneficiary account identifier | +| `returned` | Rejected by beneficiary bank after execution | +| `scheme_error` | Issue with payment provider/scheme | +| `server_error` | TrueLayer processing error | +| `unknown` | Unspecified failure reason | + +**Note:** Always handle unexpected failure reasons defensively, as new reasons may be added. + +### Checking Payout Status + +```csharp +var response = await _client.Payouts.GetPayout(payoutId); + +if (response.IsSuccessful) +{ + response.Data.Match( + authRequired => + { + Console.WriteLine($"Status: {authRequired.Status}"); + Console.WriteLine("Action: Complete authorization flow"); + Console.WriteLine($"Authorization URL: {authRequired.AuthorizationFlow}"); + }, + pending => + { + Console.WriteLine($"Status: {pending.Status}"); + Console.WriteLine("Action: Waiting for authorization or scheme processing"); + }, + authorized => + { + Console.WriteLine($"Status: {authorized.Status}"); + Console.WriteLine("Action: Payment scheme is processing the payout"); + }, + executed => + { + Console.WriteLine($"Status: {executed.Status}"); + Console.WriteLine("Terminal: Payout completed - funds deducted from merchant account"); + }, + failed => + { + Console.WriteLine($"Status: {failed.Status}"); + Console.WriteLine($"Failure Reason: {failed.FailureReason}"); + Console.WriteLine("Terminal: Payout failed - no funds deducted"); + } + ); +} +``` + +### Handling Terminal Statuses + +```csharp +public bool IsTerminalStatus(GetPayoutResponse payout) +{ + return payout.Match( + authRequired => false, + pending => false, + authorized => false, + executed => true, // Terminal - payout complete + failed => true // Terminal - payout failed + ); +} + +public async Task WaitForTerminalStatus(string payoutId, TimeSpan timeout) +{ + var startTime = DateTime.UtcNow; + + while (DateTime.UtcNow - startTime < timeout) + { + var response = await _client.Payouts.GetPayout(payoutId); + + if (!response.IsSuccessful) + { + throw new Exception($"Failed to get payout status: {response.Problem?.Detail}"); + } + + if (IsTerminalStatus(response.Data)) + { + return; // Payout reached terminal status + } + + await Task.Delay(TimeSpan.FromSeconds(5)); + } + + throw new TimeoutException("Payout did not reach terminal status within timeout"); +} +``` + +### Handling Specific Failure Reasons + +```csharp +public async Task HandlePayoutFailure(string payoutId) +{ + var response = await _client.Payouts.GetPayout(payoutId); + + if (!response.IsSuccessful) + { + return PayoutResult.Error("Failed to retrieve payout status"); + } + + return response.Data.Match( + authRequired => PayoutResult.RequiresAuth(authRequired.AuthorizationFlow), + pending => PayoutResult.Pending(), + authorized => PayoutResult.Processing(), + executed => PayoutResult.Success(), + failed => failed.FailureReason switch + { + "insufficient_funds" => PayoutResult.Error( + "Insufficient funds in merchant account. Please top up and retry." + ), + "invalid_iban" => PayoutResult.Error( + "Invalid beneficiary account details. Please verify and create new payout." + ), + "blocked" => PayoutResult.Error( + "Payout blocked by regulatory requirements. Contact support." + ), + "returned" => PayoutResult.Error( + "Payout rejected by beneficiary bank after execution." + ), + "scheme_error" => PayoutResult.Error( + "Payment scheme error. Retry may succeed." + ), + _ => PayoutResult.Error($"Payout failed: {failed.FailureReason}") + } + ); +} +``` + +## Handling Authorization Required + +Some payouts may require additional authorization: + +```csharp +var response = await _client.Payouts.CreatePayout(request, idempotencyKey); + +if (response.IsSuccessful) +{ + response.Data!.Match( + authRequired => + { + // Handle authorization flow + Console.WriteLine($"Authorization required for payout: {authRequired.Id}"); + Console.WriteLine($"Authorization URL: {authRequired.AuthorizationFlow}"); + return authRequired.Id; + }, + created => + { + // Payout created successfully + Console.WriteLine($"Payout created: {created.Id}"); + return created.Id; + } + ); +} +``` + +## Idempotency + +Prevent duplicate payouts using idempotency keys: + +```csharp +public class PayoutTransaction +{ + public string TransactionId { get; set; } + public string IdempotencyKey { get; set; } = Guid.NewGuid().ToString(); + public string PayoutId { get; set; } + public string BeneficiaryAccountNumber { get; set; } + public int AmountInMinor { get; set; } +} + +// Store idempotency key with transaction +var transaction = new PayoutTransaction +{ + TransactionId = "TXN-001", + BeneficiaryAccountNumber = "12345678", + AmountInMinor = 10000 +}; + +// Safe to retry with same key +var response = await _client.Payouts.CreatePayout( + request, + idempotencyKey: transaction.IdempotencyKey +); + +if (response.IsSuccessful) +{ + transaction.PayoutId = response.Data!.Match( + authRequired => authRequired.Id, + created => created.Id + ); + + await _repository.SaveAsync(transaction); +} +``` + +## Best Practices + +### 1. Validate Beneficiary Details + +```csharp +public class BeneficiaryValidator +{ + public bool ValidateSortCode(string sortCode) + { + // Validate format: XX-XX-XX or XXXXXX + var cleaned = sortCode.Replace("-", ""); + return cleaned.Length == 6 && cleaned.All(char.IsDigit); + } + + public bool ValidateAccountNumber(string accountNumber) + { + // UK account numbers are 8 digits + return accountNumber.Length == 8 && accountNumber.All(char.IsDigit); + } + + public bool ValidateIban(string iban) + { + // Basic IBAN validation + var cleaned = iban.Replace(" ", "").ToUpper(); + return cleaned.Length >= 15 && cleaned.Length <= 34; + } +} +``` + +### 2. Handle Errors Gracefully + +```csharp +if (!response.IsSuccessful) +{ + _logger.LogError( + "Payout creation failed. TraceId: {TraceId}, Status: {Status}, Detail: {Detail}", + response.TraceId, + response.StatusCode, + response.Problem?.Detail + ); + + // Check specific error types + if (response.StatusCode == HttpStatusCode.BadRequest) + { + // Validation errors + foreach (var (field, errors) in response.Problem?.Errors ?? new Dictionary()) + { + _logger.LogWarning("Validation error on {Field}: {Errors}", field, string.Join(", ", errors)); + } + } + + return null; +} +``` + +### 3. Store Payout Records + +```csharp +public class PayoutRecord +{ + public string Id { get; set; } + public string PayoutId { get; set; } + public string IdempotencyKey { get; set; } + public string MerchantAccountId { get; set; } + public string BeneficiaryName { get; set; } + public string BeneficiaryAccount { get; set; } + public int AmountInMinor { get; set; } + public string Currency { get; set; } + public string Status { get; set; } + public string Reference { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? ExecutedAt { get; set; } + public Dictionary Metadata { get; set; } +} +``` + +### 4. Reconciliation + +Track payouts for accounting: + +```csharp +public async Task> GetPayoutsForReconciliation(DateTime date) +{ + var payouts = await _repository.GetPayoutsByDate(date); + var reconciled = new List(); + + foreach (var payout in payouts) + { + var response = await _client.Payouts.GetPayout(payout.PayoutId); + + if (response.IsSuccessful) + { + payout.Status = response.Data.Match( + authRequired => "authorization_required", + pending => "pending", + authorized => "authorized", + executed => "executed", + failed => "failed" + ); + + reconciled.Add(payout); + } + } + + return reconciled; +} +``` + +### 5. Rate Limiting + +Implement batching for bulk payouts: + +```csharp +public async Task> ProcessBulkPayouts(List payoutRequests) +{ + var payoutIds = new List(); + var semaphore = new SemaphoreSlim(5); // Max 5 concurrent requests + + var tasks = payoutRequests.Select(async request => + { + await semaphore.WaitAsync(); + try + { + var response = await _client.Payouts.CreatePayout( + request.ToCreatePayoutRequest(), + idempotencyKey: request.IdempotencyKey + ); + + if (response.IsSuccessful) + { + return response.Data!.Match( + authRequired => authRequired.Id, + created => created.Id + ); + } + + return null; + } + finally + { + semaphore.Release(); + } + }); + + var results = await Task.WhenAll(tasks); + return results.Where(id => id != null).ToList(); +} +``` + +## Common Scenarios + +### Marketplace Seller Payout + +```csharp +public async Task PayoutToSeller(Order order, Seller seller) +{ + var beneficiary = new Beneficiary.ExternalAccount( + accountHolderName: seller.AccountName, + reference: $"Order {order.Id} - {order.CustomerName}", + accountIdentifier: new AccountIdentifier.SortCodeAccountNumber( + seller.SortCode, + seller.AccountNumber + ) + ); + + var request = new CreatePayoutRequest( + merchantAccountId: _config.MerchantAccountId, + amountInMinor: order.SellerAmount, + currency: order.Currency, + beneficiary: beneficiary, + metadata: new Dictionary + { + { "order_id", order.Id }, + { "seller_id", seller.Id }, + { "commission", order.Commission.ToString() } + } + ); + + var response = await _client.Payouts.CreatePayout( + request, + idempotencyKey: order.PayoutIdempotencyKey + ); + + if (!response.IsSuccessful) + { + throw new PayoutException($"Failed to create payout: {response.Problem?.Detail}"); + } + + var payoutId = response.Data!.Match( + authRequired => authRequired.Id, + created => created.Id + ); + + // Record payout + await _payoutRepository.CreateAsync(new PayoutRecord + { + PayoutId = payoutId, + OrderId = order.Id, + SellerId = seller.Id, + AmountInMinor = order.SellerAmount, + Status = "pending", + CreatedAt = DateTime.UtcNow + }); + + return payoutId; +} +``` + +### Refund Processing + +```csharp +public async Task IssueRefund(Payment payment, int refundAmountInMinor) +{ + var beneficiary = new Beneficiary.ExternalAccount( + accountHolderName: payment.CustomerName, + reference: $"Refund for payment {payment.Id}", + accountIdentifier: payment.SourceAccountIdentifier + ); + + var request = new CreatePayoutRequest( + merchantAccountId: payment.MerchantAccountId, + amountInMinor: refundAmountInMinor, + currency: payment.Currency, + beneficiary: beneficiary, + metadata: new Dictionary + { + { "payment_id", payment.Id }, + { "refund_reason", "customer_request" }, + { "original_amount", payment.AmountInMinor.ToString() } + } + ); + + var response = await _client.Payouts.CreatePayout( + request, + idempotencyKey: $"refund-{payment.Id}-{Guid.NewGuid()}" + ); + + return response.Data!.Match( + authRequired => authRequired.Id, + created => created.Id + ); +} +``` + +### Scheduled Payouts + +```csharp +public class ScheduledPayoutService : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + var pendingPayouts = await _repository.GetPendingPayouts(); + + foreach (var payout in pendingPayouts.Where(p => p.ScheduledFor <= DateTime.UtcNow)) + { + try + { + await ProcessPayout(payout); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to process scheduled payout {PayoutId}", payout.Id); + } + } + + await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); + } + } + + private async Task ProcessPayout(ScheduledPayout payout) + { + var request = new CreatePayoutRequest( + merchantAccountId: payout.MerchantAccountId, + amountInMinor: payout.AmountInMinor, + currency: payout.Currency, + beneficiary: payout.ToBeneficiary() + ); + + var response = await _client.Payouts.CreatePayout( + request, + idempotencyKey: payout.IdempotencyKey + ); + + if (response.IsSuccessful) + { + payout.PayoutId = response.Data!.Match( + authRequired => authRequired.Id, + created => created.Id + ); + payout.Status = "created"; + await _repository.UpdateAsync(payout); + } + } +} +``` + +## Currency Support + +Payouts support multiple currencies: + +```csharp +// GBP Payout +var gbpRequest = new CreatePayoutRequest( + merchantAccountId: gbpMerchantAccountId, + amountInMinor: 10000, + currency: Currencies.GBP, + beneficiary: gbpBeneficiary +); + +// EUR Payout +var eurRequest = new CreatePayoutRequest( + merchantAccountId: eurMerchantAccountId, + amountInMinor: 10000, + currency: Currencies.EUR, + beneficiary: eurBeneficiary +); +``` + +## Security Considerations + +### 1. Verify Beneficiary Before Payout + +```csharp +public async Task VerifyBeneficiary(string accountNumber, string sortCode) +{ + // Implement verification logic + // - Check against whitelist + // - Verify account exists + // - Validate beneficiary identity + return true; +} +``` + +### 2. Audit Trail + +```csharp +public class PayoutAuditLog +{ + public string PayoutId { get; set; } + public string InitiatedBy { get; set; } + public DateTime InitiatedAt { get; set; } + public string BeneficiaryAccount { get; set; } + public int AmountInMinor { get; set; } + public string Status { get; set; } + public string IpAddress { get; set; } +} +``` + +### 3. Two-Factor Authentication + +Consider implementing 2FA for large payouts: + +```csharp +public async Task CreateHighValuePayout(CreatePayoutRequest request, string twoFactorCode) +{ + // Verify 2FA code + if (!await _twoFactorService.VerifyCode(twoFactorCode)) + { + throw new UnauthorizedException("Invalid 2FA code"); + } + + // Proceed with payout + var response = await _client.Payouts.CreatePayout(request, Guid.NewGuid().ToString()); + + return response.Data!.Match( + authRequired => authRequired.Id, + created => created.Id + ); +} +``` + +## See Also + +- [Merchant Accounts](merchant-accounts.md) - Manage your merchant accounts +- [Error Handling](error-handling.md) - Handle payout errors +- [API Reference](xref:TrueLayer.Payouts.IPayoutsApi) - Complete API documentation diff --git a/docs/articles/providers.md b/docs/articles/providers.md new file mode 100644 index 00000000..caca07d3 --- /dev/null +++ b/docs/articles/providers.md @@ -0,0 +1,732 @@ +# Payment Providers + +Discover and retrieve information about payment providers (banks and financial institutions) that users can connect to for payments and mandates. + +## Understanding Providers + +Payment providers are the banks and financial institutions that TrueLayer connects to. Each provider has: +- **Capabilities**: What features they support (payments, payouts, VRP/mandates) +- **Logo and branding**: For display in your UI +- **Countries**: Which countries they operate in +- **Schemes**: Payment schemes they support (Faster Payments, SEPA, etc.) + +## Listing All Providers + +Retrieve all available payment providers using SearchPaymentsProvidersRequest: + +```csharp +var searchRequest = new SearchPaymentsProvidersRequest( + new AuthorizationFlow(new AuthorizationFlowConfiguration()) +); + +var response = await _client.PaymentsProviders.SearchPaymentsProviders(searchRequest); + +if (response.IsSuccessful) +{ + foreach (var provider in response.Data.Items) + { + Console.WriteLine($"{provider.Id}: {provider.DisplayName}"); + Console.WriteLine($" Country: {provider.CountryCode}"); + + // Check payment capabilities + if (provider.Capabilities.Payments?.BankTransfer != null) + { + Console.WriteLine($" Supports Payments (Release: {provider.Capabilities.Payments.BankTransfer.ReleaseChannel})"); + } + + // Check mandate capabilities + if (provider.Capabilities.Mandates?.VrpSweeping != null || + provider.Capabilities.Mandates?.VrpCommercial != null) + { + Console.WriteLine($" Supports Variable Recurring Payments"); + } + } +} +``` + +## Get Provider Details + +Get detailed information about a specific provider. + +> **See also**: The [MVC Example](https://github.com/TrueLayer/truelayer-dotnet/tree/main/examples/MvcExample) demonstrates getting provider details in [ProvidersController.cs](https://github.com/TrueLayer/truelayer-dotnet/blob/main/examples/MvcExample/Controllers/ProvidersController.cs). + +```csharp +var response = await _client.PaymentsProviders.GetPaymentsProvider("mock-payments-gb-redirect"); + +if (response.IsSuccessful) +{ + var provider = response.Data; + + Console.WriteLine($"Provider: {provider.DisplayName}"); + Console.WriteLine($"ID: {provider.Id}"); + Console.WriteLine($"Country: {provider.CountryCode}"); + Console.WriteLine($"Logo: {provider.LogoUri}"); + Console.WriteLine($"Icon: {provider.IconUri}"); + Console.WriteLine($"Background Color: {provider.BgColor}"); + + // Check payment capabilities + if (provider.Capabilities.Payments?.BankTransfer != null) + { + Console.WriteLine("Supports bank transfer payments"); + Console.WriteLine($" Release Channel: {provider.Capabilities.Payments.BankTransfer.ReleaseChannel}"); + Console.WriteLine($" Schemes: {string.Join(", ", provider.Capabilities.Payments.BankTransfer.Schemes.Select(s => s.Id))}"); + } + + // Check mandate capabilities + if (provider.Capabilities.Mandates?.VrpSweeping != null) + { + Console.WriteLine("Supports VRP Sweeping"); + } + + if (provider.Capabilities.Mandates?.VrpCommercial != null) + { + Console.WriteLine("Supports VRP Commercial"); + } +} +``` + +## Filtering Providers + +### By Country + +Filter providers by country code using the Countries parameter: + +```csharp +var searchRequest = new SearchPaymentsProvidersRequest( + new AuthorizationFlow(new AuthorizationFlowConfiguration()), + Countries: new List { "GB" } +); + +var response = await _client.PaymentsProviders.SearchPaymentsProviders(searchRequest); + +if (response.IsSuccessful) +{ + Console.WriteLine($"Found {response.Data.Items.Count} UK providers"); + + foreach (var provider in response.Data.Items) + { + Console.WriteLine($"{provider.DisplayName} ({provider.Id})"); + } +} +``` + +### By Capability + +Filter providers that support specific capabilities: + +```csharp +public async Task> GetVRPProviders() +{ + // Filter for providers with VRP sweeping capabilities + var searchRequest = new SearchPaymentsProvidersRequest( + new AuthorizationFlow(new AuthorizationFlowConfiguration()), + Capabilities: new Capabilities( + Payments: null, + Mandates: new MandatesCapabilities( + VrpSweeping: new VrpSweepingCapabilities(ReleaseChannels.GeneralAvailability), + VrpCommercial: null + ) + ) + ); + + var response = await _client.PaymentsProviders.SearchPaymentsProviders(searchRequest); + + if (!response.IsSuccessful) + { + throw new Exception("Failed to fetch providers"); + } + + return response.Data.Items.ToList(); +} + +public async Task> GetPaymentProviders() +{ + // Filter for providers with bank transfer payment capabilities + var searchRequest = new SearchPaymentsProvidersRequest( + new AuthorizationFlow(new AuthorizationFlowConfiguration()), + Capabilities: new Capabilities( + Payments: new PaymentsCapabilities( + BankTransfer: new BankTransferCapabilities( + ReleaseChannel: ReleaseChannels.GeneralAvailability, + Schemes: new List { new Scheme("faster_payments_service") } + ) + ), + Mandates: null + ) + ); + + var response = await _client.PaymentsProviders.SearchPaymentsProviders(searchRequest); + + if (!response.IsSuccessful) + { + throw new Exception("Failed to fetch providers"); + } + + return response.Data.Items.ToList(); +} +``` + +### By Multiple Criteria + +Combine multiple filters: + +```csharp +public async Task> GetUKPaymentProviders() +{ + var searchRequest = new SearchPaymentsProvidersRequest( + new AuthorizationFlow(new AuthorizationFlowConfiguration()), + Countries: new List { "GB" }, + Currencies: new List { "GBP" }, + ReleaseChannel: ReleaseChannels.GeneralAvailability, + Capabilities: new Capabilities( + Payments: new PaymentsCapabilities( + BankTransfer: new BankTransferCapabilities( + ReleaseChannel: ReleaseChannels.GeneralAvailability, + Schemes: new List { new Scheme("faster_payments_service") } + ) + ), + Mandates: null + ) + ); + + var response = await _client.PaymentsProviders.SearchPaymentsProviders(searchRequest); + + if (!response.IsSuccessful) + { + return new List(); + } + + return response.Data.Items + .OrderBy(p => p.DisplayName) + .ToList(); +} +``` + +## Provider Selection Patterns + +> **See also**: The [MVC Example](https://github.com/TrueLayer/truelayer-dotnet/tree/main/examples/MvcExample) demonstrates both preselected and user-selected provider patterns in [PaymentsController.cs](https://github.com/TrueLayer/truelayer-dotnet/blob/main/examples/MvcExample/Controllers/PaymentsController.cs). + +### User Selection with Filtering + +Allow users to choose from a filtered list of providers: + +```csharp +public async Task CreatePaymentWithFilteredProviders( + int amountInMinor, + Beneficiary beneficiary, + PaymentUserRequest userRequest) +{ + // Get UK providers that support payments using server-side filtering + var searchRequest = new SearchPaymentsProvidersRequest( + new AuthorizationFlow(new AuthorizationFlowConfiguration()), + Countries: new List { "GB" }, + Currencies: new List { "GBP" }, + Capabilities: new Capabilities( + Payments: new PaymentsCapabilities( + BankTransfer: new BankTransferCapabilities( + ReleaseChannel: ReleaseChannels.GeneralAvailability, + Schemes: new List { new Scheme("faster_payments_service") } + ) + ), + Mandates: null + ) + ); + + var providersResponse = await _client.PaymentsProviders.SearchPaymentsProviders(searchRequest); + + if (!providersResponse.IsSuccessful) + { + throw new Exception("Failed to retrieve providers"); + } + + var ukProviders = providersResponse.Data.Items + .Select(p => p.Id) + .ToArray(); + + return new CreatePaymentRequest( + amountInMinor: amountInMinor, + currency: Currencies.GBP, + paymentMethod: new CreatePaymentMethod.BankTransfer( + new CreateProviderSelection.UserSelected + { + Filter = new ProviderFilter + { + ProviderIds = ukProviders + } + }, + beneficiary + ), + user: userRequest + ); +} +``` + +### Preselected Provider + +Direct users to a specific provider (e.g., their bank): + +```csharp +public async Task CreatePaymentWithPreselectedProvider( + string providerId, + int amountInMinor, + Beneficiary beneficiary, + PaymentUserRequest userRequest) +{ + // Verify provider exists and supports payments + var providerResponse = await _client.PaymentsProviders.GetPaymentsProvider(providerId); + + if (!providerResponse.IsSuccessful) + { + throw new Exception($"Provider {providerId} not found"); + } + + if (providerResponse.Data.Capabilities.Payments?.BankTransfer == null) + { + throw new Exception($"Provider {providerId} does not support bank transfer payments"); + } + + return new CreatePaymentRequest( + amountInMinor: amountInMinor, + currency: Currencies.GBP, + paymentMethod: new CreatePaymentMethod.BankTransfer( + new CreateProviderSelection.Preselected( + providerId: providerId, + schemeSelection: new SchemeSelection.Preselected + { + SchemeId = "faster_payments_service" + } + ), + beneficiary + ), + user: userRequest + ); +} +``` + +## Building a Provider Selection UI + +### Display Providers with Logos + +```csharp +public class ProviderViewModel +{ + public string Id { get; set; } + public string Name { get; set; } + public string LogoUrl { get; set; } + public string IconUrl { get; set; } + public string BackgroundColor { get; set; } + public bool SupportsPayments { get; set; } + public bool SupportsVRP { get; set; } +} + +public async Task> GetProvidersForUI(string countryCode) +{ + // Use server-side filtering for country + var searchRequest = new SearchPaymentsProvidersRequest( + new AuthorizationFlow(new AuthorizationFlowConfiguration()), + Countries: new List { countryCode } + ); + + var response = await _client.PaymentsProviders.SearchPaymentsProviders(searchRequest); + + if (!response.IsSuccessful) + { + _logger.LogError("Failed to fetch providers: {TraceId}", response.TraceId); + return new List(); + } + + return response.Data.Items + .Select(p => new ProviderViewModel + { + Id = p.Id, + Name = p.DisplayName, + LogoUrl = p.LogoUri?.ToString(), + IconUrl = p.IconUri?.ToString(), + BackgroundColor = p.BgColor, + SupportsPayments = p.Capabilities.Payments?.BankTransfer != null, + SupportsVRP = p.Capabilities.Mandates?.VrpSweeping != null || + p.Capabilities.Mandates?.VrpCommercial != null + }) + .OrderBy(p => p.Name) + .ToList(); +} +``` + +### Grouped Provider Display + +Group providers by various criteria: + +```csharp +public class ProviderGroup +{ + public string Category { get; set; } + public List Providers { get; set; } +} + +public async Task> GetGroupedProviders() +{ + var searchRequest = new SearchPaymentsProvidersRequest( + new AuthorizationFlow(new AuthorizationFlowConfiguration()) + ); + + var response = await _client.PaymentsProviders.SearchPaymentsProviders(searchRequest); + + if (!response.IsSuccessful) + { + return new List(); + } + + var groups = new List(); + + // Group by country + var providersByCountry = response.Data.Items + .GroupBy(p => p.CountryCode) + .OrderBy(g => g.Key); + + foreach (var countryGroup in providersByCountry) + { + groups.Add(new ProviderGroup + { + Category = $"Providers in {countryGroup.Key}", + Providers = countryGroup.OrderBy(p => p.DisplayName).ToList() + }); + } + + return groups; +} + +// Alternative: Group by capability using separate API calls +public async Task> GetProvidersByCapability() +{ + var groups = new List(); + + // Get VRP-capable providers using server-side filtering + var vrpRequest = new SearchPaymentsProvidersRequest( + new AuthorizationFlow(new AuthorizationFlowConfiguration()), + Capabilities: new Capabilities( + Payments: null, + Mandates: new MandatesCapabilities( + VrpSweeping: new VrpSweepingCapabilities(ReleaseChannels.GeneralAvailability), + VrpCommercial: null + ) + ) + ); + + var vrpResponse = await _client.PaymentsProviders.SearchPaymentsProviders(vrpRequest); + + if (vrpResponse.IsSuccessful && vrpResponse.Data.Items.Any()) + { + groups.Add(new ProviderGroup + { + Category = "Supports Variable Recurring Payments", + Providers = vrpResponse.Data.Items.ToList() + }); + } + + // Get payment-only providers using server-side filtering + var paymentsRequest = new SearchPaymentsProvidersRequest( + new AuthorizationFlow(new AuthorizationFlowConfiguration()), + Capabilities: new Capabilities( + Payments: new PaymentsCapabilities( + BankTransfer: new BankTransferCapabilities( + ReleaseChannel: ReleaseChannels.GeneralAvailability, + Schemes: new List { new Scheme("faster_payments_service") } + ) + ), + Mandates: null + ) + ); + + var paymentsResponse = await _client.PaymentsProviders.SearchPaymentsProviders(paymentsRequest); + + if (paymentsResponse.IsSuccessful && paymentsResponse.Data.Items.Any()) + { + // Filter out providers that also have VRP capabilities (to get payments-only) + var vrpProviderIds = vrpResponse.IsSuccessful + ? vrpResponse.Data.Items.Select(p => p.Id).ToHashSet() + : new HashSet(); + + var paymentOnlyProviders = paymentsResponse.Data.Items + .Where(p => !vrpProviderIds.Contains(p.Id)) + .ToList(); + + if (paymentOnlyProviders.Any()) + { + groups.Add(new ProviderGroup + { + Category = "Payments Only", + Providers = paymentOnlyProviders + }); + } + } + + return groups; +} +``` + +## Caching Provider Data + +Cache provider information to reduce API calls: + +```csharp +public class ProviderCache +{ + private readonly ITrueLayerClient _client; + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(24); + + public ProviderCache( + ITrueLayerClient client, + IMemoryCache cache, + ILogger logger) + { + _client = client; + _cache = cache; + _logger = logger; + } + + public async Task> GetProviders() + { + const string cacheKey = "truelayer_providers"; + + if (_cache.TryGetValue(cacheKey, out List cached)) + { + return cached; + } + + var searchRequest = new SearchPaymentsProvidersRequest( + new AuthorizationFlow(new AuthorizationFlowConfiguration()) + ); + + var response = await _client.PaymentsProviders.SearchPaymentsProviders(searchRequest); + + if (!response.IsSuccessful) + { + _logger.LogWarning( + "Failed to fetch providers: {TraceId}", + response.TraceId + ); + + // Return empty list or throw exception + return new List(); + } + + var providers = response.Data.Items.ToList(); + + _cache.Set(cacheKey, providers, CacheDuration); + + return providers; + } + + public async Task GetProvider(string providerId) + { + var providers = await GetProviders(); + return providers.FirstOrDefault(p => p.Id == providerId); + } +} +``` + +## Best Practices + +### 1. Cache Provider Data + +Provider data rarely changes - cache it for at least 24 hours: + +```csharp +// Register cache in DI +services.AddMemoryCache(); +services.AddSingleton(); +``` + +### 2. Validate Provider Before Use + +Always verify a provider exists before creating a payment: + +```csharp +public async Task> CreatePaymentSafely( + string providerId, + CreatePaymentRequest request) +{ + // Verify provider exists + var providerResponse = await _client.PaymentsProviders.GetPaymentsProvider(providerId); + + if (!providerResponse.IsSuccessful) + { + _logger.LogWarning( + "Provider {ProviderId} not found or unavailable", + providerId + ); + + // Return error or fallback to user selection + throw new InvalidOperationException($"Provider {providerId} is not available"); + } + + // Create payment + return await _client.Payments.CreatePayment( + request, + idempotencyKey: Guid.NewGuid().ToString() + ); +} +``` + +### 3. Display Provider Logos + +Use provider logos for better UX: + +```csharp +// In your view model +public string GetProviderLogoUrl(PaymentsProvider provider) +{ + // Use logo for large displays, icon for small displays + return provider.LogoUri?.ToString() ?? provider.IconUri?.ToString(); +} + +// HTML example +// @provider.Name +``` + +### 4. Filter by User's Country + +Show only relevant providers to users: + +```csharp +public async Task> GetProvidersForUser(User user) +{ + // Use server-side filtering by user's country + var searchRequest = new SearchPaymentsProvidersRequest( + new AuthorizationFlow(new AuthorizationFlowConfiguration()), + Countries: new List { user.CountryCode } + ); + + var response = await _client.PaymentsProviders.SearchPaymentsProviders(searchRequest); + + if (!response.IsSuccessful) + { + return new List(); + } + + return response.Data.Items + .OrderBy(p => p.DisplayName) + .ToList(); +} +``` + +## Common Scenarios + +### Bank Selection Page + +```csharp +[HttpGet("select-bank")] +public async Task SelectBank(string paymentId) +{ + // Use server-side filtering to get UK payment providers + var searchRequest = new SearchPaymentsProvidersRequest( + new AuthorizationFlow(new AuthorizationFlowConfiguration()), + Countries: new List { "GB" }, + Currencies: new List { "GBP" }, + Capabilities: new Capabilities( + Payments: new PaymentsCapabilities( + BankTransfer: new BankTransferCapabilities( + ReleaseChannel: ReleaseChannels.GeneralAvailability, + Schemes: new List { new Scheme("faster_payments_service") } + ) + ), + Mandates: null + ) + ); + + var response = await _client.PaymentsProviders.SearchPaymentsProviders(searchRequest); + + if (!response.IsSuccessful) + { + // Handle error appropriately + return View(new BankSelectionViewModel + { + PaymentId = paymentId, + Providers = new List() + }); + } + + var ukProviders = response.Data.Items + .Select(p => new ProviderViewModel + { + Id = p.Id, + Name = p.DisplayName, + LogoUrl = p.LogoUri?.ToString(), + BackgroundColor = p.BgColor + }) + .ToList(); + + return View(new BankSelectionViewModel + { + PaymentId = paymentId, + Providers = ukProviders + }); +} +``` + +### Dynamic Provider Filtering + +Filter providers based on transaction requirements: + +```csharp +public async Task> GetProvidersForTransaction( + string countryCode, + string currency, + bool requiresVRP, + int amountInMinor) +{ + // Build capabilities filter based on requirements + Capabilities? capabilities = null; + + if (requiresVRP) + { + capabilities = new Capabilities( + Payments: null, + Mandates: new MandatesCapabilities( + VrpSweeping: new VrpSweepingCapabilities(ReleaseChannels.GeneralAvailability), + VrpCommercial: null + ) + ); + } + else + { + capabilities = new Capabilities( + Payments: new PaymentsCapabilities( + BankTransfer: new BankTransferCapabilities( + ReleaseChannel: ReleaseChannels.GeneralAvailability, + Schemes: new List { new Scheme("faster_payments_service") } + ) + ), + Mandates: null + ); + } + + var searchRequest = new SearchPaymentsProvidersRequest( + new AuthorizationFlow(new AuthorizationFlowConfiguration()), + Countries: new List { countryCode }, + Currencies: new List { currency }, + ReleaseChannel: ReleaseChannels.GeneralAvailability, + Capabilities: capabilities + ); + + var response = await _client.PaymentsProviders.SearchPaymentsProviders(searchRequest); + + if (!response.IsSuccessful) + { + return new List(); + } + + // Could add additional client-side filtering based on amount limits, etc. + return response.Data.Items + .OrderBy(p => p.DisplayName) + .ToList(); +} +``` + +## See Also + +- [Payments](payments.md) - Using providers in payment creation +- [Mandates](mandates.md) - Providers that support VRP/mandates +- [API Reference](xref:TrueLayer.PaymentsProviders.IPaymentsProvidersApi) diff --git a/docs/articles/toc.yml b/docs/articles/toc.yml new file mode 100644 index 00000000..116a7d4d --- /dev/null +++ b/docs/articles/toc.yml @@ -0,0 +1,26 @@ +- name: Getting Started + items: + - name: Installation + href: installation.md + - name: Authentication + href: authentication.md + - name: Error Handling + href: error-handling.md +- name: Core Features + items: + - name: Payments + href: payments.md + - name: Payouts + href: payouts.md + - name: Mandates + href: mandates.md + - name: Providers + href: providers.md + - name: Merchant Accounts + href: merchant-accounts.md +- name: Advanced Topics + items: + - name: Multiple Clients + href: multiple-clients.md + - name: Configuration + href: configuration.md diff --git a/docs/docfx.json b/docs/docfx.json index 1703fa50..62e45870 100644 --- a/docs/docfx.json +++ b/docs/docfx.json @@ -13,46 +13,70 @@ ] } ], - "dest": "obj/api" + "dest": "api", + "includePrivateMembers": false, + "disableGitFeatures": false, + "disableDefaultFilter": false } ], "build": { "content": [ { "files": [ - "**/*.yml" - ], - "src": "obj/api", - "dest": "api" + "api/**.yml", + "api/index.md" + ] }, { "files": [ "*.md", "toc.yml" ] + }, + { + "files": [ + "articles/**.md", + "articles/**/toc.yml" + ] } ], "resource": [ { "files": [ - "img/**" + "img/**", + "articles/img/**" ] } ], - "overwrite": "specs/*.md", "globalMetadata": { "_appTitle": "TrueLayer.NET Documentation", + "_appName": "TrueLayer.NET", + "_appLogoPath": "img/tl-logo.svg", + "_appFaviconPath": "img/tl-logo.svg", "_enableSearch": true, - "_appLogoPath": "https://truelayer-public-assets.s3-eu-west-1.amazonaws.com/logo/mark/tl-yellow.svg" + "_gitContribute": { + "repo": "https://github.com/TrueLayer/truelayer-dotnet", + "branch": "main" + }, + "_gitUrlPattern": "github" + }, + "fileMetadata": { + "_disableContribution": { + "api/**.yml": true + } }, - "markdownEngineName": "markdig", - "dest": "../artifacts/docs", "template": [ - "default", - "templates/material" + "default" ], + "markdownEngineName": "markdig", + "markdownEngineProperties": { + "markdigExtensions": [ + "advanced" + ] + }, "xrefService": [ "https://xref.docs.microsoft.com/query?uid={uid}" - ] + ], + "dest": "_site" } } diff --git a/docs/img/tl-logo.svg b/docs/img/tl-logo.svg new file mode 100644 index 00000000..cfc89b36 --- /dev/null +++ b/docs/img/tl-logo.svg @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/docs/index.md b/docs/index.md index 91c07b45..98b47752 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,160 +1,139 @@ -# TrueLayer.NET +# TrueLayer.NET Documentation [![NuGet](https://img.shields.io/nuget/v/TrueLayer.Client.svg)](https://www.nuget.org/packages/TrueLayer.Client) -[![NuGet](https://img.shields.io/nuget/vpre/TrueLayer.Client?label=Pre-release)](https://www.nuget.org/packages/TrueLayer.Client) [![NuGet](https://img.shields.io/nuget/dt/TrueLayer.Client.svg)](https://www.nuget.org/packages/TrueLayer.Client) -[![License](https://img.shields.io/:license-mit-blue.svg)](https://truelayer.mit-license.org/) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://truelayer.mit-license.org/) +[![Build](https://github.com/TrueLayer/truelayer-dotnet/workflows/Build/badge.svg)](https://github.com/TrueLayer/truelayer-dotnet/actions) -## Installation +Welcome to the official TrueLayer .NET client library documentation. This library provides convenient access to the TrueLayer APIs for applications built with .NET. -Using the [.NET Core command-line interface (CLI) tools](https://docs.microsoft.com/en-us/dotnet/core/tools/): +## What is TrueLayer? -```sh -dotnet add package TrueLayer.Client -``` - -Using the [NuGet Command Line Interface (CLI)](https://docs.microsoft.com/en-us/nuget/tools/nuget-exe-cli-reference) - -```sh -nuget install TrueLayer.Client -``` - -Using the [Package Manager Console](https://docs.microsoft.com/en-us/nuget/tools/package-manager-console): - -```powershell -Install-Package TrueLayer.Client -``` - -From within Visual Studio: - -1. Open the Solution Explorer. -2. Right-click on a project within your solution. -3. Click on *Manage NuGet Packages...* -4. Click on the *Browse* tab and search for "TrueLayer". -5. Click on the `TrueLayer` package, select the appropriate version in the - right-tab and click *Install*. - -## Usage +[TrueLayer](https://truelayer.com) is a leading open banking platform that enables secure access to financial data and payment initiation services. Our APIs allow you to: -### Prerequisites +- **Initiate Payments** - Create and manage single and recurring payments +- **Process Payouts** - Send funds to beneficiaries +- **Manage Mandates** - Set up recurring payment mandates +- **Access Provider Information** - Discover available payment providers +- **Manage Merchant Accounts** - View and manage your merchant accounts -First [sign up](https://console.truelayer.com/) for a developer account. Follow the instructions to set up a new application and obtain your Client ID and Secret. +## Supported Frameworks -Next, generate a signing key pair used to sign API requests. +The library currently supports: +- **.NET 9.0** +- **.NET 8.0** -To generate a private key, run: +## Quick Start -```sh -docker run --rm -v ${PWD}:/out -w /out -it alpine/openssl ecparam -genkey -name secp521r1 -noout -out ec512-private-key.pem -``` - -To obtain the public key, run: - -```sh -docker run --rm -v ${PWD}:/out -w /out -it alpine/openssl ec -in ec512-private-key.pem -pubout -out ec512-public-key.pem -``` - -Navigate to the Payments settings section of the TrueLayer console and upload your public key. Obtain the Key Id. - -### Configure Settings +### Installation -Add your Client ID, Secret and Signing Key ID to `appsettings.json` or any other supported [configuration provider](https://docs.microsoft.com/en-us/dotnet/core/extensions/configuration). +Install the library via NuGet: - -```json -{ - "TrueLayer": { - "ClientId": "your id", - "ClientSecret": "your secret", - "UseSandbox": true, - "Payments": { - "SigningKey": { - "KeyId": "85eeb2da-702c-4f4b-bf9a-e98af5fd47c3" - } - } - } -} +```bash +dotnet add package TrueLayer.Client ``` -### Initialize TrueLayer.NET +### Basic Setup -Register the TrueLayer client in `Startup.cs` or `Program.cs`: +1. [Sign up](https://console.truelayer.com/) for a TrueLayer developer account +2. Create an application and obtain your Client ID and Secret +3. Generate signing keys for payment requests +4. Configure your application: -```c# -public IConfiguration Configuration { get; } - -public void ConfigureServices(IServiceCollection services) +```csharp +services.AddTrueLayer(configuration, options => { - services.AddTrueLayer(configuration, options => + if (options.Payments?.SigningKey != null) { - if (options.Payments?.SigningKey != null) - { - // For demo purposes only. Private key should be stored securely - options.Payments.SigningKey.PrivateKey = File.ReadAllText("ec512-private-key.pem"); - } - }); -} + options.Payments.SigningKey.PrivateKey = File.ReadAllText("ec512-private-key.pem"); + } +}, +authTokenCachingStrategy: AuthTokenCachingStrategies.InMemory); ``` -### Make a payment - -Inject `ITrueLayerClient` into your classes: +### Your First Payment -```c# -public class MyService +```csharp +public class PaymentService { private readonly ITrueLayerClient _client; - public MyService(ITrueLayerClient client) + public PaymentService(ITrueLayerClient client) { _client = client; } - public async Task MakePayment() + public async Task CreatePayment() { - var paymentRequest = new CreatePaymentRequest( + var request = new CreatePaymentRequest( amountInMinor: 100, currency: Currencies.GBP, paymentMethod: new CreatePaymentMethod.BankTransfer( - new CreateProviderSelection.UserSelected - { - Filter = new ProviderFilter - { - ProviderIds = new[] { "mock-payments-gb-redirect" } - } - }, + new CreateProviderSelection.UserSelected(), new Beneficiary.ExternalAccount( "TrueLayer", "truelayer-dotnet", new AccountIdentifier.SortCodeAccountNumber("567890", "12345678") ) ), - user: new PaymentUserRequest("Jane Doe", "jane.doe@example.com", "0123456789") + user: new PaymentUserRequest("Jane Doe", "jane.doe@example.com") ); - var apiResponse = await _client.Payments.CreatePayment( - paymentRequest, + var response = await _client.Payments.CreatePayment( + request, idempotencyKey: Guid.NewGuid().ToString() ); - if (!apiResponse.IsSuccessful) + if (!response.IsSuccessful) { - return HandleFailure( - apiResponse.StatusCode, - // Includes details of any errors - apiResponse.Problem - ) + throw new Exception($"Payment creation failed: {response.Problem}"); } - // Pass the ResourceToken to the TrueLayer Web or Mobile SDK - - // or, redirect to the TrueLayer Hosted Payment Page - string hostedPaymentPageUrl = _client.Payments.CreateHostedPaymentPageLink( - apiResponse.Data!.Id, - apiResponse.Data!.ResourceToken, - new Uri("https://redirect.yourdomain.com")); - - return Redirect(hostedPaymentPageUrl); + return response.Data!.Match( + authRequired => authRequired.HostedPage!.Uri.ToString(), + authorized => $"Payment authorized: {authorized.Id}", + failed => $"Payment failed: {failed.Status}", + authorizing => $"Payment authorizing: {authorizing.Id}" + ); } } ``` + +## Documentation Structure + +### Getting Started +- [Installation](articles/installation.md) - Detailed installation and setup guide +- [Authentication](articles/authentication.md) - Configure authentication and token caching +- [Error Handling](articles/error-handling.md) - Handle errors and responses effectively + +### Core Features +- [Payments](articles/payments.md) - Create and manage payments, refunds, and cancellations +- [Payouts](articles/payouts.md) - Process payouts to beneficiaries +- [Mandates](articles/mandates.md) - Set up and manage recurring payment mandates +- [Providers](articles/providers.md) - Discover and work with payment providers +- [Merchant Accounts](articles/merchant-accounts.md) - Manage your merchant accounts + +### Advanced Topics +- [Multiple Clients](articles/multiple-clients.md) - Configure multiple TrueLayer clients +- [Custom Configuration](articles/configuration.md) - Advanced configuration options + +### API Reference +Browse the complete **API Reference** in the navigation menu for detailed documentation of all types and members. + +## Examples + +Check out our [MVC Example](https://github.com/TrueLayer/truelayer-dotnet/tree/main/examples/MvcExample) for a complete working application demonstrating all major features. + +## Getting Help + +- **Issues**: Report bugs or request features on [GitHub Issues](https://github.com/TrueLayer/truelayer-dotnet/issues) +- **API Reference**: Browse the complete [API documentation](https://docs.truelayer.com) +- **Support**: Contact [TrueLayer Support](https://truelayer.com/support) + +## Contributing + +We welcome contributions! Please see our [Contributing Guide](https://github.com/TrueLayer/truelayer-dotnet/blob/main/CODE_OF_CONDUCT.md) for details. + +## License + +This project is licensed under the [MIT License](https://truelayer.mit-license.org/). diff --git a/docs/toc.yml b/docs/toc.yml index 57c61324..2460f9eb 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -1,4 +1,8 @@ - name: Home href: index.md -- name: API Documentation - href: obj/api/ \ No newline at end of file +- name: Articles + href: articles/ +- name: API Reference + href: api/ +- name: GitHub + href: https://github.com/TrueLayer/truelayer-dotnet \ No newline at end of file