Skip to content

Commit 8a6656d

Browse files
feat(ACL-248)(ACL-291): Add support for polish payouts and sub merchant field (#242)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent f0b75e5 commit 8a6656d

File tree

9 files changed

+553
-6
lines changed

9 files changed

+553
-6
lines changed

build.cake

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
// Install .NET Core Global tools.
2-
#tool "dotnet:?package=dotnet-reportgenerator-globaltool&version=5.4.1"
2+
#tool "dotnet:?package=dotnet-reportgenerator-globaltool&version=5.4.9"
33
#tool "dotnet:?package=coveralls.net&version=4.0.1"
44

55
// Install addins
6-
#addin nuget:?package=Cake.Coverlet&version=4.0.1
6+
#addin nuget:?package=Cake.Coverlet&version=5.1.1
77

88
///////////////////////////////////////////////////////////////////////////////
99
// ARGUMENTS

src/TrueLayer/Payments/Model/CreatePaymentRequest.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ public class CreatePaymentRequest
2424
/// If provided, the start authorization flow endpoint does not need to be called</param>
2525
/// <param name="metadata">Add to the payment a list of custom key-value pairs as metadata</param>
2626
/// <param name="riskAssessment">The risk assessment and the payment_creditable webhook configuration.</param>
27+
/// <param name="subMerchants">Sub-merchants information for the payment</param>
2728
public CreatePaymentRequest(
2829
long amountInMinor,
2930
string currency,
@@ -32,7 +33,8 @@ public CreatePaymentRequest(
3233
RelatedProducts? relatedProducts = null,
3334
StartAuthorizationFlowRequest? authorizationFlow = null,
3435
Dictionary<string, string>? metadata = null,
35-
RiskAssessment? riskAssessment = null)
36+
RiskAssessment? riskAssessment = null,
37+
SubMerchants? subMerchants = null)
3638
{
3739
AmountInMinor = amountInMinor.GreaterThan(0, nameof(amountInMinor));
3840
Currency = currency.NotNullOrWhiteSpace(nameof(currency));
@@ -42,6 +44,7 @@ public CreatePaymentRequest(
4244
AuthorizationFlow = authorizationFlow;
4345
Metadata = metadata;
4446
RiskAssessment = riskAssessment;
47+
SubMerchants = subMerchants;
4548
}
4649

4750
/// <summary>
@@ -84,5 +87,10 @@ public CreatePaymentRequest(
8487
/// Gets the risk assessment configuration
8588
/// </summary>
8689
public RiskAssessment? RiskAssessment { get; }
90+
91+
/// <summary>
92+
/// Gets the sub-merchants information for the payment
93+
/// </summary>
94+
public SubMerchants? SubMerchants { get; }
8795
}
8896
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
using OneOf;
2+
using TrueLayer.Common;
3+
using TrueLayer.Serialization;
4+
5+
namespace TrueLayer.Payments.Model
6+
{
7+
using UltimateCounterpartyUnion = OneOf<SubMerchants.BusinessDivision, SubMerchants.BusinessClient>;
8+
9+
/// <summary>
10+
/// Represents sub-merchants information for payment requests
11+
/// </summary>
12+
public class SubMerchants
13+
{
14+
/// <summary>
15+
/// Creates a new <see cref="SubMerchants"/> instance
16+
/// </summary>
17+
/// <param name="ultimateCounterparty">The ultimate counterparty information</param>
18+
public SubMerchants(UltimateCounterpartyUnion ultimateCounterparty)
19+
{
20+
UltimateCounterparty = ultimateCounterparty;
21+
}
22+
23+
/// <summary>
24+
/// Gets the ultimate counterparty information
25+
/// </summary>
26+
public UltimateCounterpartyUnion UltimateCounterparty { get; }
27+
28+
/// <summary>
29+
/// Represents a business division counterparty
30+
/// </summary>
31+
[JsonDiscriminator("business_division")]
32+
public class BusinessDivision
33+
{
34+
/// <summary>
35+
/// Creates a new <see cref="BusinessDivision"/> instance
36+
/// </summary>
37+
/// <param name="id">UUID generated by you</param>
38+
/// <param name="name">Name of the division</param>
39+
public BusinessDivision(string id, string name)
40+
{
41+
Type = "business_division";
42+
Id = id.NotNullOrWhiteSpace(nameof(id));
43+
Name = name.NotNullOrWhiteSpace(nameof(name));
44+
}
45+
46+
/// <summary>
47+
/// Gets the type of the counterparty
48+
/// </summary>
49+
public string Type { get; }
50+
51+
/// <summary>
52+
/// Gets the UUID generated by you
53+
/// </summary>
54+
public string Id { get; }
55+
56+
/// <summary>
57+
/// Gets the name of the division
58+
/// </summary>
59+
public string Name { get; }
60+
}
61+
62+
/// <summary>
63+
/// Represents a business client counterparty
64+
/// </summary>
65+
[JsonDiscriminator("business_client")]
66+
public class BusinessClient
67+
{
68+
/// <summary>
69+
/// Creates a new <see cref="BusinessClient"/> instance
70+
/// </summary>
71+
/// <param name="tradingName">Trading name of the merchant</param>
72+
/// <param name="commercialName">Commercial name different from trading name (optional)</param>
73+
/// <param name="url">Business website URL (optional)</param>
74+
/// <param name="mcc">Merchant category code (optional)</param>
75+
/// <param name="registrationNumber">Business registration number (optional if address provided)</param>
76+
/// <param name="address">Business address (optional)</param>
77+
public BusinessClient(
78+
string tradingName,
79+
string? commercialName = null,
80+
string? url = null,
81+
string? mcc = null,
82+
string? registrationNumber = null,
83+
Address? address = null)
84+
{
85+
Type = "business_client";
86+
TradingName = tradingName.NotNullOrWhiteSpace(nameof(tradingName));
87+
CommercialName = commercialName.NotEmptyOrWhiteSpace(nameof(commercialName));
88+
Url = url.NotEmptyOrWhiteSpace(nameof(url));
89+
Mcc = mcc.NotEmptyOrWhiteSpace(nameof(mcc));
90+
RegistrationNumber = registrationNumber.NotEmptyOrWhiteSpace(nameof(registrationNumber));
91+
Address = address;
92+
}
93+
94+
/// <summary>
95+
/// Gets the type of the counterparty
96+
/// </summary>
97+
public string Type { get; }
98+
99+
/// <summary>
100+
/// Gets the trading name of the merchant
101+
/// </summary>
102+
public string TradingName { get; }
103+
104+
/// <summary>
105+
/// Gets the commercial name different from trading name
106+
/// </summary>
107+
public string? CommercialName { get; }
108+
109+
/// <summary>
110+
/// Gets the business website URL
111+
/// </summary>
112+
public string? Url { get; }
113+
114+
/// <summary>
115+
/// Gets the merchant category code
116+
/// </summary>
117+
public string? Mcc { get; }
118+
119+
/// <summary>
120+
/// Gets the business registration number
121+
/// </summary>
122+
public string? RegistrationNumber { get; }
123+
124+
/// <summary>
125+
/// Gets the business address
126+
/// </summary>
127+
public Address? Address { get; }
128+
}
129+
}
130+
}

src/TrueLayer/Payouts/Model/AccountIdentifier.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,5 +73,39 @@ public SortCodeAccountNumber(string sortCode, string accountNumber)
7373
public string AccountNumber { get; }
7474

7575
}
76+
77+
/// <summary>
78+
/// Defines a bank account identified by a Polish NRB
79+
/// </summary>
80+
/// <value></value>
81+
[JsonDiscriminator(Discriminator)]
82+
public record Nrb : IDiscriminated
83+
{
84+
public const string Discriminator = "nrb";
85+
86+
/// <summary>
87+
/// Creates a new <see cref="Nrb"/> instance
88+
/// </summary>
89+
/// <param name="value">
90+
/// Valid Polish NRB (no spaces).
91+
/// Consists of 2 check digits, followed by an 8 digit bank branch number, and then by a 16 digit bank account number.
92+
/// Equivalent to a Polish IBAN with the country code removed.
93+
/// </param>
94+
public Nrb(string value)
95+
{
96+
Value = value.NotNullOrWhiteSpace(nameof(value));
97+
}
98+
99+
/// <summary>
100+
/// Gets the scheme identifier type
101+
/// </summary>
102+
public string Type => Discriminator;
103+
104+
/// <summary>
105+
/// Gets the NRB value
106+
/// </summary>
107+
[JsonPropertyName(Discriminator)]
108+
public string Value { get; }
109+
}
76110
}
77111
}

src/TrueLayer/Payouts/Model/Beneficiary.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
namespace TrueLayer.Payouts.Model
99
{
10-
using AccountIdentifierUnion = OneOf<Iban, SortCodeAccountNumber>;
10+
using AccountIdentifierUnion = OneOf<Iban, SortCodeAccountNumber, Nrb>;
1111

1212
public static class Beneficiary
1313
{

test/TrueLayer.AcceptanceTests/PaymentTests.cs

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,67 @@ public async Task Can_Create_Merchant_Account_Eur_Payment()
164164
hppUri.Should().NotBeNullOrWhiteSpace();
165165
}
166166

167+
[Fact]
168+
public async Task Can_Create_Payment_With_SubMerchants_BusinessDivision()
169+
{
170+
var subMerchants = new SubMerchants(new SubMerchants.BusinessDivision(
171+
id: Guid.NewGuid().ToString(),
172+
name: "Test Division"));
173+
174+
var paymentRequest = CreateTestPaymentRequest(
175+
new Provider.UserSelected
176+
{
177+
Filter = new ProviderFilter { ProviderIds = ["mock-payments-gb-redirect"] },
178+
SchemeSelection = new SchemeSelection.InstantOnly { AllowRemitterFee = true },
179+
},
180+
subMerchants: subMerchants);
181+
182+
var response = await _fixture.TlClients[0].Payments.CreatePayment(
183+
paymentRequest, idempotencyKey: Guid.NewGuid().ToString());
184+
185+
response.StatusCode.Should().Be(HttpStatusCode.Created);
186+
var authorizationRequired = response.Data.AsT0;
187+
188+
authorizationRequired.Id.Should().NotBeNullOrWhiteSpace();
189+
authorizationRequired.ResourceToken.Should().NotBeNullOrWhiteSpace();
190+
authorizationRequired.User.Should().NotBeNull();
191+
authorizationRequired.User.Id.Should().NotBeNullOrWhiteSpace();
192+
authorizationRequired.Status.Should().Be("authorization_required");
193+
}
194+
195+
[Fact]
196+
public async Task Can_Create_Payment_With_SubMerchants_BusinessClient()
197+
{
198+
var address = new Address("London", "England", "EC1R 4RB", "GB", "1 Hardwick St");
199+
var subMerchants = new SubMerchants(new SubMerchants.BusinessClient(
200+
tradingName: "Test Trading Company",
201+
commercialName: "Test Commercial Name",
202+
url: "https://example.com",
203+
mcc: "1234",
204+
registrationNumber: "REG123456",
205+
address: address));
206+
207+
var paymentRequest = CreateTestPaymentRequest(
208+
new Provider.UserSelected
209+
{
210+
Filter = new ProviderFilter { ProviderIds = ["mock-payments-gb-redirect"] },
211+
SchemeSelection = new SchemeSelection.InstantOnly { AllowRemitterFee = true },
212+
},
213+
subMerchants: subMerchants);
214+
215+
var response = await _fixture.TlClients[0].Payments.CreatePayment(
216+
paymentRequest, idempotencyKey: Guid.NewGuid().ToString());
217+
218+
response.StatusCode.Should().Be(HttpStatusCode.Created);
219+
var authorizationRequired = response.Data.AsT0;
220+
221+
authorizationRequired.Id.Should().NotBeNullOrWhiteSpace();
222+
authorizationRequired.ResourceToken.Should().NotBeNullOrWhiteSpace();
223+
authorizationRequired.User.Should().NotBeNull();
224+
authorizationRequired.User.Id.Should().NotBeNullOrWhiteSpace();
225+
authorizationRequired.Status.Should().Be("authorization_required");
226+
}
227+
167228
[Fact]
168229
public async Task Can_Create_Payment_With_Auth_Flow()
169230
{
@@ -530,7 +591,8 @@ private static CreatePaymentRequest CreateTestPaymentRequest(
530591
RelatedProducts? relatedProducts = null,
531592
BeneficiaryUnion? beneficiary = null,
532593
Retry.BaseRetry? retry = null,
533-
bool initAuthorizationFlow = false)
594+
bool initAuthorizationFlow = false,
595+
SubMerchants? subMerchants = null)
534596
{
535597
accountIdentifier ??= new AccountIdentifier.SortCodeAccountNumber("567890", "12345678");
536598
providerSelection ??= new Provider.Preselected("mock-payments-gb-redirect",
@@ -568,7 +630,8 @@ private static CreatePaymentRequest CreateTestPaymentRequest(
568630
["test-key-1"] = "test-value-1",
569631
["test-key-2"] = "test-value-2",
570632
},
571-
riskAssessment: new RiskAssessment("test")
633+
riskAssessment: new RiskAssessment("test"),
634+
subMerchants: subMerchants
572635
);
573636
}
574637

test/TrueLayer.AcceptanceTests/PayoutTests.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,18 @@ public async Task Can_create_payout()
3030
response.Data!.Id.Should().NotBeNullOrWhiteSpace();
3131
}
3232

33+
[Fact]
34+
public async Task Can_create_pln_payout()
35+
{
36+
CreatePayoutRequest payoutRequest = CreatePlnPayoutRequest();
37+
38+
var response = await _fixture.TlClients[0].Payouts.CreatePayout(payoutRequest);
39+
40+
response.StatusCode.Should().Be(HttpStatusCode.Accepted);
41+
response.Data.Should().NotBeNull();
42+
response.Data!.Id.Should().NotBeNullOrWhiteSpace();
43+
}
44+
3345
[Fact]
3446
public async Task Can_get_payout()
3547
{
@@ -83,5 +95,20 @@ private CreatePayoutRequest CreatePayoutRequest()
8395
metadata: new() { { "a", "b" } },
8496
schemeSelection: new SchemeSelection.InstantOnly()
8597
);
98+
99+
private static CreatePayoutRequest CreatePlnPayoutRequest()
100+
=> new(
101+
"fdb6007b-78c0-dbc0-60dd-d4c6f6908e3b", //pln merchant account
102+
100,
103+
Currencies.PLN,
104+
new Beneficiary.ExternalAccount(
105+
"Ms. Lucky",
106+
"truelayer-dotnet",
107+
new AccountIdentifier.Iban("GB25CLRB04066800046876"),
108+
dateOfBirth: new DateTime(1970, 12, 31),
109+
address: new Address("London", "England", "EC1R 4RB", "GB", "1 Hardwick St")),
110+
metadata: new() { { "a", "b" } },
111+
schemeSelection: new SchemeSelection.InstantOnly()
112+
);
86113
}
87114
}

0 commit comments

Comments
 (0)