Skip to content

Commit

Permalink
Payment using Nets Easy. (#624)
Browse files Browse the repository at this point in the history
Adds a new type of process task for adding payment to apps. For now, using Nets Easy as payment processor.
  • Loading branch information
bjorntore authored May 24, 2024
1 parent 290f815 commit 71c56ca
Show file tree
Hide file tree
Showing 59 changed files with 4,792 additions and 19 deletions.
9 changes: 3 additions & 6 deletions src/Altinn.App.Api/Controllers/ActionsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ public async Task<ActionResult<UserActionResponse>> Perform(
}

UserActionContext userActionContext =
new(instance, userId.Value, actionRequest.ButtonId, actionRequest.Metadata);
new(instance, userId.Value, actionRequest.ButtonId, actionRequest.Metadata, language);
IUserAction? actionHandler = _userActionService.GetActionHandler(action);
if (actionHandler == null)
{
Expand All @@ -148,12 +148,8 @@ public async Task<ActionResult<UserActionResponse>> Perform(
}

UserActionResult result = await actionHandler.HandleAction(userActionContext);
if (result.ResultType == ResultType.Redirect)
{
return new RedirectResult(result.RedirectUrl ?? throw new ProcessException("Redirect URL missing"));
}

if (result.ResultType != ResultType.Success)
if (result.ResultType == ResultType.Failure)
{
return StatusCode(
statusCode: result.ErrorType switch
Expand Down Expand Up @@ -183,6 +179,7 @@ public async Task<ActionResult<UserActionResponse>> Perform(
actionRequest.IgnoredValidators,
language
),
RedirectUrl = result.RedirectUrl,
}
);
}
Expand Down
115 changes: 115 additions & 0 deletions src/Altinn.App.Api/Controllers/PaymentController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
using Altinn.App.Api.Infrastructure.Filters;
using Altinn.App.Core.Features.Payment;
using Altinn.App.Core.Features.Payment.Exceptions;
using Altinn.App.Core.Features.Payment.Models;
using Altinn.App.Core.Features.Payment.Services;
using Altinn.App.Core.Internal.Instances;
using Altinn.App.Core.Internal.Process;
using Altinn.App.Core.Internal.Process.Elements.AltinnExtensionProperties;
using Altinn.Platform.Storage.Interface.Models;
using Microsoft.AspNetCore.Mvc;

namespace Altinn.App.Api.Controllers;

/// <summary>
/// Controller for handling payment operations.
/// </summary>
[AutoValidateAntiforgeryTokenIfAuthCookie]
[ApiController]
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
[Route("{org}/{app}/instances/{instanceOwnerPartyId:int}/{instanceGuid:guid}/payment")]
public class PaymentController : ControllerBase
{
private readonly IInstanceClient _instanceClient;
private readonly IProcessReader _processReader;
private readonly IPaymentService _paymentService;
private readonly IOrderDetailsCalculator? _orderDetailsCalculator;

/// <summary>
/// Initializes a new instance of the <see cref="PaymentController"/> class.
/// </summary>
public PaymentController(
IInstanceClient instanceClient,
IProcessReader processReader,
IPaymentService paymentService,
IOrderDetailsCalculator? orderDetailsCalculator = null
)
{
_instanceClient = instanceClient;
_processReader = processReader;
_paymentService = paymentService;
_orderDetailsCalculator = orderDetailsCalculator;
}

/// <summary>
/// Get updated payment information for the instance. Will contact the payment processor to check the status of the payment. Current task must be a payment task. See payment related documentation.
/// </summary>
/// <param name="org">unique identifier of the organisation responsible for the app</param>
/// <param name="app">application identifier which is unique within an organisation</param>
/// <param name="instanceOwnerPartyId">unique id of the party that this the owner of the instance</param>
/// <param name="instanceGuid">unique id to identify the instance</param>
/// <param name="language">The currently used language by the user (or null if not available)</param>
/// <returns>An object containing updated payment information</returns>
[HttpGet]
[ProducesResponseType(typeof(PaymentInformation), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetPaymentInformation(
[FromRoute] string org,
[FromRoute] string app,
[FromRoute] int instanceOwnerPartyId,
[FromRoute] Guid instanceGuid,
[FromQuery] string? language = null
)
{
Instance instance = await _instanceClient.GetInstance(app, org, instanceOwnerPartyId, instanceGuid);
AltinnPaymentConfiguration? paymentConfiguration = _processReader
.GetAltinnTaskExtension(instance.Process.CurrentTask.ElementId)
?.PaymentConfiguration;

if (paymentConfiguration == null)
{
throw new PaymentException("Payment configuration not found in AltinnTaskExtension");
}

PaymentInformation paymentInformation = await _paymentService.CheckAndStorePaymentStatus(
instance,
paymentConfiguration,
language
);

return Ok(paymentInformation);
}

/// <summary>
/// Run order details calculations and return the result. Does not require the current task to be a payment task.
/// </summary>
/// <param name="org">unique identifier of the organisation responsible for the app</param>
/// <param name="app">application identifier which is unique within an organisation</param>
/// <param name="instanceOwnerPartyId">unique id of the party that this the owner of the instance</param>
/// <param name="instanceGuid">unique id to identify the instance</param>
/// <param name="language">The currently used language by the user (or null if not available)</param>
/// <returns>An object containing updated payment information</returns>
[HttpGet("order-details")]
[ProducesResponseType(typeof(OrderDetails), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetOrderDetails(
[FromRoute] string org,
[FromRoute] string app,
[FromRoute] int instanceOwnerPartyId,
[FromRoute] Guid instanceGuid,
[FromQuery] string? language = null
)
{
if (_orderDetailsCalculator == null)
{
throw new PaymentException(
"You must add an implementation of the IOrderDetailsCalculator interface to the DI container. See payment related documentation."
);
}

Instance instance = await _instanceClient.GetInstance(app, org, instanceOwnerPartyId, instanceGuid);
OrderDetails orderDetails = await _orderDetailsCalculator.CalculateOrderDetails(instance, language);

return Ok(orderDetails);
}
}
6 changes: 6 additions & 0 deletions src/Altinn.App.Api/Models/UserActionResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,10 @@ public class UserActionResponse
/// </summary>
[JsonPropertyName("error")]
public ActionError? Error { get; set; }

/// <summary>
/// If the action requires the client to redirect to another url, this property should be set
/// </summary>
[JsonPropertyName("redirectUrl")]
public Uri? RedirectUrl { get; set; }
}
20 changes: 20 additions & 0 deletions src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
using Altinn.App.Core.Features.Notifications.Sms;
using Altinn.App.Core.Features.Options;
using Altinn.App.Core.Features.PageOrder;
using Altinn.App.Core.Features.Payment.Processors;
using Altinn.App.Core.Features.Payment.Processors.Nets;
using Altinn.App.Core.Features.Payment.Services;
using Altinn.App.Core.Features.Pdf;
using Altinn.App.Core.Features.Validation;
using Altinn.App.Core.Features.Validation.Default;
Expand Down Expand Up @@ -177,6 +180,7 @@ IWebHostEnvironment env
AddAppOptions(services);
AddActionServices(services);
AddPdfServices(services);
AddNetsPaymentServices(services, configuration);
AddSignatureServices(services);
AddEventServices(services);
AddNotificationServices(services);
Expand Down Expand Up @@ -261,6 +265,22 @@ private static void AddPdfServices(IServiceCollection services)
#pragma warning restore CS0618 // Type or member is obsolete
}

private static void AddNetsPaymentServices(this IServiceCollection services, IConfiguration configuration)
{
IConfigurationSection configurationSection = configuration.GetSection("NetsPaymentSettings");

if (configurationSection.Exists())
{
services.Configure<NetsPaymentSettings>(configurationSection);
services.AddHttpClient<INetsClient, NetsClient>();
services.AddTransient<IPaymentProcessor, NetsPaymentProcessor>();
}

services.AddTransient<IPaymentService, PaymentService>();
services.AddTransient<IProcessTask, PaymentProcessTask>();
services.AddTransient<IUserAction, PaymentUserAction>();
}

private static void AddSignatureServices(IServiceCollection services)
{
services.AddHttpClient<ISignClient, SignClient>();
Expand Down
90 changes: 90 additions & 0 deletions src/Altinn.App.Core/Features/Action/PaymentUserAction.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using Altinn.App.Core.Features.Payment.Models;
using Altinn.App.Core.Features.Payment.Services;
using Altinn.App.Core.Internal.App;
using Altinn.App.Core.Internal.Process;
using Altinn.App.Core.Internal.Process.Elements;
using Altinn.App.Core.Internal.Process.Elements.AltinnExtensionProperties;
using Altinn.App.Core.Models.Process;
using Altinn.App.Core.Models.UserAction;
using Microsoft.Extensions.Logging;

namespace Altinn.App.Core.Features.Action
{
/// <summary>
/// User action for payment
/// </summary>
internal class PaymentUserAction : IUserAction
{
private readonly IProcessReader _processReader;
private readonly ILogger<PaymentUserAction> _logger;
private readonly IPaymentService _paymentService;

/// <summary>
/// Initializes a new instance of the <see cref="PaymentUserAction"/> class
/// </summary>
public PaymentUserAction(
IProcessReader processReader,
IPaymentService paymentService,
ILogger<PaymentUserAction> logger
)
{
_processReader = processReader;
_paymentService = paymentService;
_logger = logger;
}

/// <inheritdoc />
public string Id => "pay";

/// <inheritdoc />
public async Task<UserActionResult> HandleAction(UserActionContext context)
{
if (
_processReader.GetFlowElement(context.Instance.Process.CurrentTask.ElementId)
is not ProcessTask currentTask
)
{
return UserActionResult.FailureResult(
new ActionError() { Code = "NoProcessTask", Message = "Current task is not a process task." }
);
}

_logger.LogInformation(
"Payment action handler invoked for instance {Id}. In task: {CurrentTaskId}",
context.Instance.Id,
currentTask.Id
);

AltinnPaymentConfiguration? paymentConfiguration = currentTask
.ExtensionElements
?.TaskExtension
?.PaymentConfiguration;
if (paymentConfiguration == null)
{
throw new ApplicationConfigException(
"PaymentConfig is missing in the payment process task configuration."
);
}

(PaymentInformation paymentInformation, bool alreadyPaid) = await _paymentService.StartPayment(
context.Instance,
paymentConfiguration,
context.Language
);

if (alreadyPaid)
{
return UserActionResult.FailureResult(
error: new ActionError { Code = "PaymentAlreadyCompleted", Message = "Payment already completed." },
errorType: ProcessErrorType.Conflict
);
}

string? paymentDetailsRedirectUrl = paymentInformation.PaymentDetails?.RedirectUrl;

return paymentDetailsRedirectUrl == null
? UserActionResult.SuccessResult()
: UserActionResult.RedirectResult(new Uri(paymentDetailsRedirectUrl));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace Altinn.App.Core.Features.Payment.Exceptions
{
/// <summary>
/// Represents an exception that is thrown when an error occurs during payment processing.
/// </summary>
public class PaymentException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="PaymentException"/> class.
/// </summary>
/// <param name="message"></param>
public PaymentException(string message)
: base(message) { }
}
}
21 changes: 21 additions & 0 deletions src/Altinn.App.Core/Features/Payment/IOrderDetailsCalculator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace Altinn.App.Core.Features.Payment;

using Altinn.Platform.Storage.Interface.Models;
using Models;

/// <summary>
/// Interface that app developers need to implement in order to use the payment feature
/// </summary>
public interface IOrderDetailsCalculator
{
/// <summary>
/// Method that calculates an order based on an instance.
/// </summary>
/// <remarks>
/// The instance (and its data) needs to be fetched based on the <see cref="Instance"/> if the calculation
/// depends on instance or data properties.
/// This method can be called multiple times for the same instance, in order to preview the price before payment starts.
/// </remarks>
/// <returns>The Payment order that contains information about the requested payment</returns>
Task<OrderDetails> CalculateOrderDetails(Instance instance, string? language);
}
37 changes: 37 additions & 0 deletions src/Altinn.App.Core/Features/Payment/Models/Address.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
namespace Altinn.App.Core.Features.Payment.Models;

/// <summary>
/// Represents an address.
/// </summary>
public class Address
{
/// <summary>
/// The name associated with the address.
/// </summary>
public string? Name { get; set; }

/// <summary>
/// The first line of the address.
/// </summary>
public string? AddressLine1 { get; set; }

/// <summary>
/// The second line of the address.
/// </summary>
public string? AddressLine2 { get; set; }

/// <summary>
/// The postal code of the address.
/// </summary>
public string? PostalCode { get; set; }

/// <summary>
/// The city of the address.
/// </summary>
public string? City { get; set; }

/// <summary>
/// The country of the address.
/// </summary>
public string? Country { get; set; }
}
17 changes: 17 additions & 0 deletions src/Altinn.App.Core/Features/Payment/Models/CardDetails.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace Altinn.App.Core.Features.Payment.Models;

/// <summary>
/// The details of a payment card.
/// </summary>
public class CardDetails
{
/// <summary>
/// The masked PAN of the card.
/// </summary>
public string? MaskedPan { get; set; }

/// <summary>
/// The expiry date of the card.
/// </summary>
public string? ExpiryDate { get; set; }
}
12 changes: 12 additions & 0 deletions src/Altinn.App.Core/Features/Payment/Models/InvoiceDetails.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Altinn.App.Core.Features.Payment.Models;

/// <summary>
/// The details of an invoice.
/// </summary>
public class InvoiceDetails
{
/// <summary>
/// The invoice number, if available.
/// </summary>
public string? InvoiceNumber { get; set; }
}
Loading

0 comments on commit 71c56ca

Please sign in to comment.