diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..830dd2e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "chat.tools.terminal.autoApprove": { + "ForEach-Object": true, + "dotnet test": true + } +} \ No newline at end of file diff --git a/HOWTO_01_Payment_csharp/Program.cs b/HOWTO_01_Payment_csharp/Program.cs index 36c8ae6..338457c 100644 --- a/HOWTO_01_Payment_csharp/Program.cs +++ b/HOWTO_01_Payment_csharp/Program.cs @@ -1,4 +1,5 @@ using System; +using System.Net; using System.Threading.Tasks; using fiskaltrust.DevKit.POSSystemAPI.lib; using fiskaltrust.DevKit.POSSystemAPI.lib.DTO; @@ -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); @@ -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 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 payResult = await ftPosAPI.Pay.PaymentAsync(payItemRequest, fiskaltrust.Payment.DTO.PaymentProtocol.use_auto, cbTerminalID, operationId); - ///////////////////////////////////////////////////////////////////////////////////////////////// // Check Result @@ -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) { @@ -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; } } diff --git a/HOWTO_08_pay_sign_issue_csharp/Program.cs b/HOWTO_08_pay_sign_issue_csharp/Program.cs index 5df51a7..895719c 100644 --- a/HOWTO_08_pay_sign_issue_csharp/Program.cs +++ b/HOWTO_08_pay_sign_issue_csharp/Program.cs @@ -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; @@ -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(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 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; + } + 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."); diff --git a/libPosSystemAPI.Test/IntegrationTestsPayment.cs b/libPosSystemAPI.Test/IntegrationTestsPayment.cs index 99c9c2e..d835b8e 100644 --- a/libPosSystemAPI.Test/IntegrationTestsPayment.cs +++ b/libPosSystemAPI.Test/IntegrationTestsPayment.cs @@ -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; @@ -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 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 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 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); + } + } } diff --git a/libPosSystemAPI/PosAPIClient/PosAPIPay.cs b/libPosSystemAPI/PosAPIClient/PosAPIPay.cs index 2e92d1e..1663fca 100644 --- a/libPosSystemAPI/PosAPIClient/PosAPIPay.cs +++ b/libPosSystemAPI/PosAPIClient/PosAPIPay.cs @@ -44,6 +44,34 @@ public async Task> PaymentAsync(PayItemRequest cbPay } return await OperationExecutorImpl.Instance.ExecuteOperationAsync(rBuilder); } + + /// + /// Queries the result of a previously started payment transaction. + /// + /// + /// Operation ID of the original /pay request. + /// The same value is sent as x-operation-id header. + /// + /// + /// The payment response associated with the provided operation ID. + /// + /// + /// API behavior of /PayResponse: + /// + /// If the payment is already finished, the response is returned immediately. + /// If the payment is still in progress, the request blocks until completion and then returns the result. + /// If the operation ID is unknown, the API returns a bad request response. + /// + /// + public async Task> GetPayResponseAsync(Guid operationId) + { + // https://docs.fiskaltrust.cloud/apis/pos-system-api#tag/SynchronAPI/paths/~1PayResponse/post + var rBuilder = new APIRequestBuilder() + .SetMethod(HttpMethod.Post) + .SetPath("/PayResponse") + .SetOperationID(operationId); + return await OperationExecutorImpl.Instance.ExecuteOperationAsync(rBuilder); + } /// /// diff --git a/libPosSystemAPI/PosAPIUtils/Utils.cs b/libPosSystemAPI/PosAPIUtils/Utils.cs index 86509a8..d2a2ef4 100644 --- a/libPosSystemAPI/PosAPIUtils/Utils.cs +++ b/libPosSystemAPI/PosAPIUtils/Utils.cs @@ -113,7 +113,7 @@ public static async Task 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) @@ -158,7 +158,7 @@ public static async Task 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)