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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"chat.tools.terminal.autoApprove": {
"ForEach-Object": true,
"dotnet test": true
}
}
73 changes: 47 additions & 26 deletions HOWTO_01_Payment_csharp/Program.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Net;
using System.Threading.Tasks;
using fiskaltrust.DevKit.POSSystemAPI.lib;
using fiskaltrust.DevKit.POSSystemAPI.lib.DTO;
Expand All @@ -11,7 +12,7 @@ internal class Program
{
static async Task Main(string[] args)
{
var result = await Utils.InitFtPosSystemAPIClient ();
var result = await Utils.InitFtPosSystemAPIClient (httpTimeoutSeconds: 25);
if (!result.success)
{
Console.WriteLine(result.errorMsg);
Expand Down Expand Up @@ -49,32 +50,34 @@ static async Task Main(string[] args)

Logger.LogInfo($"Executing a simple payment of {amount} EUR via card...");

// IMPORTANT: We do provide the operationID here to be able to retry the operation in case of failure
//
// Note: The backend will check the operationID and the payload of the request.
// And if it is identical to a previous request it will return the result of that previous request instead of executing a new payment request.
// IMPORTANT: We do provide the operationID here so we can query the status of this payment operation later
// via POST /PayResponse in case the initial /pay request fails due to communication issues.
//
// Note: The backend identifies a payment operation by its operationID. POST /PayResponse with the same
// operationID returns the result of the original /pay operation - it does NOT trigger a new
// payment at the terminal. This is the recommended pattern to recover from communication
// failures.
// See https://docs.fiskaltrust.cloud/apis/pos-system-api for details.
Guid operationId = Guid.NewGuid();
var payItemRequest = new PayItemRequest
{
Description = "Card",
Amount = amount,
};

// Initial /pay request:
// - with the defined amount
// - allow the target device to select the payment protocol (= use_auto; see payment config in target device / InStore App)
// - use the selected terminal ID so the request is processed by the specified terminal
// - we provide the operation ID so we can query the result later via /PayResponse
Logger.LogInfo($"Sending payment request using operation ID: {operationId}");
ExecutedResult<PayResponse> payResult = await ftPosAPI.Pay.PaymentAsync(payItemRequest, fiskaltrust.Payment.DTO.PaymentProtocol.use_auto, cbTerminalID, operationId);
bool lastCallWasPayResponse = false;

// In a real setup you might want to use a cancellation token or something similar to not endless retry.
// We send the request until it either succeeds or we get a negative response from the backend. (= we retry if we do not get a response for whatever reason)
//
// IMPORTANT: To simplify the retry logic please check out HOWTO_08_pay_sign_issue and the ftPosAPIOperationRunner class there.
// We poll the backend until we either get a definitive response or the user aborts.
while (true)
{
Logger.LogInfo($"Sending payment request using operation ID: {operationId}");
// execute the payment
// - with with defined amount
// - allow the target device to select the payment protocol (= use_auto; see payment config in target device / InStore App)
// - the terminal ID is defined here ("term1") so the request will be processed by the specified terminal (if the terminal is configured also with a terminal ID)
// IMPORTANT: In a real setup you might want to define a specific terminal ID here to target a specific payment terminal device; especially when multiple payment terminals are registered for the same cashbox!
// - we provide the operation ID to be able to retry in case of failure
var payItemRequest = new PayItemRequest
{
Description = "Card",
Amount = amount,
};
ExecutedResult<PayResponse> payResult = await ftPosAPI.Pay.PaymentAsync(payItemRequest, fiskaltrust.Payment.DTO.PaymentProtocol.use_auto, cbTerminalID, operationId);

/////////////////////////////////////////////////////////////////////////////////////////////////
// Check Result

Expand All @@ -91,8 +94,24 @@ static async Task Main(string[] args)
}
else
{
// NO --> we received a response but it was a negative one --> do not retry, inform user (and the user might decide to retry manually)
Logger.LogInfo("Payment status request failed: " + payResult.ErrorMessage);
// NO --> we received a response but it was a negative one.
//
// Special case: if the last call was /PayResponse and the backend returns 400 BadRequest,
// it means the operation ID is unknown to the backend. The most likely cause is that the
// original /pay request never reached the backend (so no payment was started, no terminal
// was contacted, no charge happened). We deliberately do NOT automatically resend /pay
// here - it is up to the POS integrator to decide how to handle this situation in their
// own POS (e.g. ask the cashier to confirm before retrying).
if (lastCallWasPayResponse && payResult.Operation.Response?.StatusCode == HttpStatusCode.BadRequest)
{
Logger.LogError("/PayResponse returned 400 BadRequest - operation ID is unknown to the backend.");
Logger.LogError("This most likely means the original /pay request never reached the backend, so no payment was started.");
Logger.LogError("It is up to the POS integration to decide whether to resend /pay (with the same or a new operation ID) or abort.");
}
else
{
Logger.LogInfo("Payment status request failed: " + payResult.ErrorMessage);
}
ErrorResponse? errResp = await payResult.Operation.GetResponseErrorAsync();
if (errResp != null)
{
Expand All @@ -107,10 +126,12 @@ static async Task Main(string[] args)
}
else
{
// NO --> communication issue with backend service --> retry
// NO --> communication issue with backend service. Lets query the result of the already started operation via POST /PayResponse using the same operation ID.
Logger.LogError($"Payment request failed: {payResult.ErrorMessage}");
Logger.LogInfo("Retrying payment request after 3s delay...");
Logger.LogInfo("Querying payment status via /PayResponse after 3s delay...");
await Task.Delay(3000);
payResult = await ftPosAPI.Pay.GetPayResponseAsync(operationId);
lastCallWasPayResponse = true;
continue;
}
}
Expand Down
62 changes: 56 additions & 6 deletions HOWTO_08_pay_sign_issue_csharp/Program.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using fiskaltrust.DevKit.POSSystemAPI.lib;
using fiskaltrust.DevKit.POSSystemAPI.lib.DTO;
Expand Down Expand Up @@ -64,13 +65,62 @@ static async Task Main(string[] args)
Amount = totalAmount,
Description = "Card"
};
var payRunner = new ftPosAPIOperationRunner();
(PayResponse? pResp, string errorMsg) = await payRunner.Execute<PayResponse>(async () =>

// For /pay we do NOT use the generic ftPosAPIOperationRunner (as we do for /sign and /issue below):
// Instead we send /pay exactly once and - on a communication failure - query the result of the
// already started operation via POST /PayResponse using the same operation ID.
// See https://docs.fiskaltrust.cloud/apis/pos-system-api for details.
Guid payOperationId = Guid.NewGuid();
Logger.LogInfo($"Sending payment request using operation ID: {payOperationId}");
ExecutedResult<PayResponse> payResult = await ftPosAPI.Pay.PaymentAsync(payRequest, PaymentProtocol.use_auto, cbTerminalID, payOperationId);
bool lastCallWasPayResponse = false;

PayResponse? pResp = null;
string errorMsg = string.Empty;
while (true)
{

return await ftPosAPI.Pay.PaymentAsync(payRequest, PaymentProtocol.use_auto, cbTerminalID, payRunner.OperationID);
});

if (payResult.Executed)
{
if (payResult.Operation.IsSuccess)
{
pResp = await payResult.Operation.GetResponseAsAsync();
}
else
{
// Special case: if the last call was /PayResponse and the backend returns 400 BadRequest,
// it means the operation ID is unknown to the backend. The most likely cause is that the
// original /pay request never reached the backend (so no payment was started, no terminal
// was contacted, no charge happened). We deliberately do NOT automatically resend /pay
// here - it is up to the POS integrator to decide how to handle this situation in their
// own POS (e.g. ask the cashier to confirm before retrying).
if (lastCallWasPayResponse && payResult.Operation.Response?.StatusCode == HttpStatusCode.BadRequest)
{
Logger.LogError("/PayResponse returned 400 BadRequest - operation ID is unknown to the backend.");
Logger.LogError("This most likely means the original /pay request never reached the backend, so no payment was started.");
Logger.LogError("It is up to the POS integration to decide whether to resend /pay (with the same or a new operation ID) or abort.");
errorMsg = "/PayResponse returned 400 BadRequest (unknown operation ID).";
}
else
{
errorMsg = payResult.ErrorMessage;
}
Comment on lines +103 to +106
ErrorResponse? errResp = await payResult.Operation.GetResponseErrorAsync();
if (errResp != null)
{
Logger.LogError(errResp.ToString());
}
}
break;
}
else
{
Logger.LogError($"Payment request failed: {payResult.ErrorMessage}");
Logger.LogInfo("Querying payment status via /PayResponse after 3s delay...");
await Task.Delay(3000);
payResult = await ftPosAPI.Pay.GetPayResponseAsync(payOperationId);
lastCallWasPayResponse = true;
}
}
if (pResp == null)
{
Logger.LogError("Payment failed: " + errorMsg + ". Aborting further processing.");
Expand Down
77 changes: 77 additions & 0 deletions libPosSystemAPI.Test/IntegrationTestsPayment.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Text.Json;
using System.Threading.Tasks;
using fiskaltrust.DevKit.POSSystemAPI.lib.DTO;
using fiskaltrust.DevKit.POSSystemAPI.lib.PosAPIClient;
Expand Down Expand Up @@ -54,6 +55,82 @@ public async Task TestPayment()
Assert.True(piCaseData.Provider["Action"].GetString() == Payment.DTO.PayAction.payment.ToString());
}

[IntegrationFact]
public async Task TestPayResponseByOperationId()
{
await InitFtPosSystemAPIClient();

Guid operationID = Guid.NewGuid();

ExecutedResult<PayResponse> paymentResult = await ftPosAPI.Pay.PaymentAsync(new DTO.PayItemRequest()
{
Description = "Test Item PayResponse",
Amount = 11.0m,
}, Payment.DTO.PaymentProtocol.use_auto, null, operationID);

Assert.True(paymentResult.Executed, "PaymentAsync failed: " + paymentResult.ErrorMessage);
Assert.True(paymentResult.Operation.IsSuccess, "PaymentAsync operation was not successful");

PayResponse paymentResponse = await paymentResult.Operation.GetResponseAsAsync();
Assert.NotNull(paymentResponse);

ExecutedResult<PayResponse> payResponseResult = await ftPosAPI.Pay.GetPayResponseAsync(operationID);

Assert.True(payResponseResult.Executed, "PayResponseAsync failed: " + payResponseResult.ErrorMessage);
Assert.True(string.IsNullOrEmpty(payResponseResult.ErrorMessage));
Assert.NotNull(payResponseResult.Operation);
Assert.True(payResponseResult.Operation.IsSuccess);
Assert.Equal(operationID, payResponseResult.Operation.OperationID);

PayResponse payResponse = await payResponseResult.Operation.GetResponseAsAsync();
Assert.NotNull(payResponse);
Assert.NotNull(payResponse.ftPayItems);
Assert.NotEmpty(payResponse.ftPayItems);
Assert.Equal(Payment.DTO.PaymentProtocol.use_auto, payResponse.Protocol);

Assert.Equal(paymentResponse.Protocol, payResponse.Protocol);
Assert.Equal(paymentResponse.ftQueueID, payResponse.ftQueueID);
Assert.Equal(paymentResponse.ftPayItems.Length, payResponse.ftPayItems.Length);

for (int i = 0; i < paymentResponse.ftPayItems.Length; i++)
{
PayItem expected = paymentResponse.ftPayItems[i];
PayItem actual = payResponse.ftPayItems[i];

Assert.Equal(expected.ftPayItemId, actual.ftPayItemId);
Assert.Equal(expected.Description, actual.Description);
Assert.Equal(expected.Amount, actual.Amount);
Assert.Equal(expected.ftPayItemCase, actual.ftPayItemCase);
Assert.Equal(expected.Moment, actual.Moment);

string expectedCaseData = JsonSerializer.Serialize(expected.ftPayItemCaseData);
string actualCaseData = JsonSerializer.Serialize(actual.ftPayItemCaseData);
Assert.Equal(expectedCaseData, actualCaseData);
}
}

[IntegrationFact]
public async Task TestPayResponseByUnknownOperationId()
{
await InitFtPosSystemAPIClient();

Guid unknownOperationID = Guid.NewGuid();

ExecutedResult<PayResponse> payResponseResult = await ftPosAPI.Pay.GetPayResponseAsync(unknownOperationID);

Assert.True(payResponseResult.Executed, "GetPayResponseAsync failed to execute: " + payResponseResult.ErrorMessage);
Assert.NotNull(payResponseResult.Operation);
Assert.Equal(unknownOperationID, payResponseResult.Operation.OperationID);
Assert.False(payResponseResult.Operation.IsSuccess, "Unknown operation ID should not return success.");

ErrorResponse? errorResponse = await payResponseResult.Operation.GetResponseErrorAsync();
Assert.NotNull(errorResponse);

Assert.Equal(404, errorResponse.Status);
Assert.NotNull(errorResponse.Title);
Assert.NotNull(errorResponse.Detail);
}


}
}
28 changes: 28 additions & 0 deletions libPosSystemAPI/PosAPIClient/PosAPIPay.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,34 @@ public async Task<ExecutedResult<PayResponse>> PaymentAsync(PayItemRequest cbPay
}
return await OperationExecutorImpl<PaymentRequest, PayResponse>.Instance.ExecuteOperationAsync(rBuilder);
}

/// <summary>
/// Queries the result of a previously started payment transaction.
/// </summary>
/// <param name="operationId">
/// Operation ID of the original <c>/pay</c> request.
/// The same value is sent as <c>x-operation-id</c> header.
/// </param>
/// <returns>
/// The payment response associated with the provided operation ID.
/// </returns>
/// <remarks>
/// API behavior of <c>/PayResponse</c>:
/// <list type="bullet">
/// <item><description>If the payment is already finished, the response is returned immediately.</description></item>
/// <item><description>If the payment is still in progress, the request blocks until completion and then returns the result.</description></item>
/// <item><description>If the operation ID is unknown, the API returns a bad request response.</description></item>
/// </list>
/// </remarks>
public async Task<ExecutedResult<PayResponse>> GetPayResponseAsync(Guid operationId)
{
// https://docs.fiskaltrust.cloud/apis/pos-system-api#tag/SynchronAPI/paths/~1PayResponse/post
var rBuilder = new APIRequestBuilder<PaymentRequest, PayResponse>()
.SetMethod(HttpMethod.Post)
.SetPath("/PayResponse")
.SetOperationID(operationId);
return await OperationExecutorImpl<PaymentRequest, PayResponse>.Instance.ExecuteOperationAsync(rBuilder);
}

/// <summary>
///
Expand Down
4 changes: 2 additions & 2 deletions libPosSystemAPI/PosAPIUtils/Utils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ public static async Task<bool> PingFtPosApiAvailable(string posSystemAPIUrl = "h
}
}

public static async Task<(bool success, string errorMsg)> InitFtPosSystemAPIClient ()
public static async Task<(bool success, string errorMsg)> InitFtPosSystemAPIClient (int httpTimeoutSeconds = 75)
{
(Guid ftCashboxID, string ftCashboxAccessToken)? credentials = GetCashboxCredentialsFromEnvironment();
if (credentials == null)
Expand Down Expand Up @@ -158,7 +158,7 @@ public static async Task<bool> PingFtPosApiAvailable(string posSystemAPIUrl = "h
}

Logger.LogInfo("Initializing ftPosAPI for cashbox ID: " + credentials.Value.ftCashboxID);
ftPosAPI.Init(credentials.Value.ftCashboxID, credentials.Value.ftCashboxAccessToken, posSystemID.Value, posSystemAPIUrl, 75);
ftPosAPI.Init(credentials.Value.ftCashboxID, credentials.Value.ftCashboxAccessToken, posSystemID.Value, posSystemAPIUrl, httpTimeoutSeconds);

(bool success, _) = await ftPosAPI.EchoAsync();
if (!success)
Expand Down
Loading