Skip to content

Commit

Permalink
RefundPayment on PurchasesController (#284)
Browse files Browse the repository at this point in the history
This hasn't been tested, as it is currently impossible
See
https://developer.mobilepay.dk/docs/app-payments/transition-to-one-platform#test
  • Loading branch information
fredpetersen authored Sep 24, 2024
1 parent 521c0b4 commit 6fc0181
Show file tree
Hide file tree
Showing 6 changed files with 299 additions and 2 deletions.
8 changes: 8 additions & 0 deletions coffeecard/CoffeeCard.Library/Services/v2/IPurchaseService.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using CoffeeCard.MobilePay.Generated.Api.PaymentsApi;
using CoffeeCard.Models.DataTransferObjects.v2.MobilePay;
using CoffeeCard.Models.DataTransferObjects.v2.Purchase;
using CoffeeCard.Models.Entities;
Expand Down Expand Up @@ -45,5 +46,12 @@ public interface IPurchaseService : IDisposable
/// <param name="voucherCode">Voucher code</param>
/// <param name="user">user redeeming the voucher</param>
Task<SimplePurchaseResponse> RedeemVoucher(string voucherCode, User user);

/// <summary>
/// Refund a purchase
/// </summary>
/// <param name="paymentId">The payment to refund</param>
/// <returns>The purchase after being refunded</returns>
Task<SimplePurchaseResponse> RefundPurchase(int paymentId);
}
}
44 changes: 44 additions & 0 deletions coffeecard/CoffeeCard.Library/Services/v2/PurchaseService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Threading.Tasks;
using CoffeeCard.Common.Errors;
using CoffeeCard.Library.Persistence;
using CoffeeCard.MobilePay.Generated.Api.PaymentsApi;
using CoffeeCard.MobilePay.Service.v2;
using CoffeeCard.Models.DataTransferObjects.v2.MobilePay;
using CoffeeCard.Models.DataTransferObjects.v2.Products;
Expand Down Expand Up @@ -319,6 +320,49 @@ public async Task<SimplePurchaseResponse> RedeemVoucher(string voucherCode, User
};
}

public async Task<SimplePurchaseResponse> RefundPurchase(int paymentId)
{
var purchase = await _context.Purchases
.Where(p => p.Id == paymentId)
.Include(p => p.PurchasedBy)
.FirstOrDefaultAsync();
if (purchase == null)
{
Log.Error("No purchase was found by Purchase Id: {Id}", paymentId);
throw new EntityNotFoundException($"No purchase was found by Purchase Id: {paymentId}");
}


if (purchase.Status != PurchaseStatus.Completed)
{
Log.Error("Purchase {PurchaseId} is not in state Completed. Cannot refund", purchase.Id);
throw new IllegalUserOperationException($"Purchase {purchase.Id} is not in state Completed. Cannot refund");
}

var refundSuccess = await _mobilePayPaymentsService.RefundPayment(Guid.Parse(purchase.ExternalTransactionId));
if (!refundSuccess)
{
Log.Error("Refund of Purchase {PurchaseId} failed", purchase.Id);
throw new InvalidOperationException($"Refund of Purchase {purchase.Id} failed");
}

purchase.Status = PurchaseStatus.Refunded;
await _context.SaveChangesAsync();

Log.Information("Refunded Purchase {PurchaseId}", purchase.Id);

return new SimplePurchaseResponse
{
Id = purchase.Id,
DateCreated = purchase.DateCreated,
ProductId = purchase.ProductId,
ProductName = purchase.ProductName,
NumberOfTickets = purchase.NumberOfTickets,
TotalAmount = purchase.Price,
PurchaseStatus = purchase.Status
};
}

public void Dispose()
{
_context?.Dispose();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,7 @@ public interface IMobilePayPaymentsService
/// </summary>
/// <returns>All Payment Points</returns>
Task<PaymentPointsList> GetPaymentPoints();

Task<bool> RefundPayment(Guid paymentId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,36 @@ public async Task<MobilePayPaymentDetails> GetPayment(Guid paymentId)
}
}

public async Task<bool> RefundPayment(Guid paymentId)
{
try
{
IssueRefundRequest issueRefundRequest = new IssueRefundRequest
{
PaymentId = paymentId
};
try
{
var response = await _paymentsApi.IssueRefundAsync(issueRefundRequest);
return true;
}
catch (ApiException e)
{
Log.Error(e, "MobilePay RefundPayment failed with HTTP {StatusCode}. Message: {Message}", e.StatusCode, e.Message);
return false;
}

}
catch (ApiException<ErrorResponse> e)
{
var errorResponse = e.Result;
Log.Error(e,
"MobilePay RefundPayment failed with HTTP {StatusCode}. ErrorCode: {ErrorCode} Message: {Message} CorrelationId: {CorrelationId}",
e.StatusCode, errorResponse.Code, errorResponse.Message, errorResponse.CorrelationId);
throw new MobilePayApiException(e.StatusCode, errorResponse.Message, errorResponse.Code);
}
}

public async Task CapturePayment(Guid paymentId, int amountInDanishKroner)
{
try
Expand Down
193 changes: 193 additions & 0 deletions coffeecard/CoffeeCard.Tests.Unit/Services/v2/PurchaseServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using CoffeeCard.Common.Errors;
using CoffeeCard.Library.Persistence;
using CoffeeCard.Library.Services.v2;
using CoffeeCard.MobilePay.Generated.Api.PaymentsApi;
using CoffeeCard.MobilePay.Service.v2;
using CoffeeCard.Models.DataTransferObjects.v2.MobilePay;
using CoffeeCard.Models.DataTransferObjects.v2.Purchase;
Expand Down Expand Up @@ -261,6 +262,198 @@ public async Task InitiatePurchaseAddsTicketsToUserWhenFree(Product product)
Assert.Equal(product.NumberOfTickets, userUpdated.Tickets.Count);
}

[Theory(DisplayName = "RefundPurchase refunds a purchase")]
[MemberData(nameof(ProductGenerator))]
public async Task RefundPurchaseRefundsAPurchase(Product product)
{
// Arrange
var builder = new DbContextOptionsBuilder<CoffeeCardContext>()
.UseInMemoryDatabase(nameof(RefundPurchaseRefundsAPurchase) +
product.Name);

var databaseSettings = new DatabaseSettings
{
SchemaName = "test"
};
var environmentSettings = new EnvironmentSettings()
{
EnvironmentType = EnvironmentType.Test
};

await using var context = new CoffeeCardContext(builder.Options, databaseSettings, environmentSettings);
var user = new User
{
Id = 1,
Name = "User1",
Email = "email@email.test",
Password = "password",
Salt = "salt",
DateCreated = new DateTime(year: 2020, month: 11, day: 11),
IsVerified = true,
PrivacyActivated = false,
UserGroup = UserGroup.Board,
UserState = UserState.Active
};

var purchase = new Purchase
{
Id = 1,
ProductId = product.Id,
ProductName = product.Name,
Price = product.Price,
NumberOfTickets = product.NumberOfTickets,
ExternalTransactionId = Guid.NewGuid().ToString(),
PurchasedBy = user,
OrderId = "test",
};
context.Add(user);
context.Add(product);
context.Add(purchase);
await context.SaveChangesAsync();

var mobilePayService = new Mock<IMobilePayPaymentsService>();
mobilePayService.Setup(mps => mps.RefundPayment(It.IsAny<Guid>()))
.ReturnsAsync(true);
var mailService = new Mock<Library.Services.IEmailService>();
var productService = new ProductService(context);
var ticketService = new TicketService(context, new Mock<IStatisticService>().Object);
var purchaseService = new PurchaseService(context, mobilePayService.Object, ticketService,
mailService.Object, productService);

// Act
var refund = await purchaseService.RefundPurchase(purchase.Id);

// Assert
Assert.Equal(PurchaseStatus.Refunded, refund.PurchaseStatus);
}

[Theory(DisplayName = "RefundPurchase throws exception when purchase not found")]
[MemberData(nameof(ProductGenerator))]
public async Task RefundPurchaseThrowsExceptionWhenNotAllowed(Product product)
{
// Arrange
var builder = new DbContextOptionsBuilder<
CoffeeCardContext>()
.UseInMemoryDatabase(nameof(RefundPurchaseThrowsExceptionWhenNotAllowed) +
product.Name);

var databaseSettings = new DatabaseSettings
{
SchemaName = "test"
};
var environmentSettings = new EnvironmentSettings()
{
EnvironmentType = EnvironmentType.Test
};

await using var context = new CoffeeCardContext(builder.Options, databaseSettings, environmentSettings);
var user = new User
{
Id = 1,
Name = "User1",
Email = "email@email.test",
Password = "password",
Salt = "salt",
DateCreated = new DateTime(year: 2020, month: 11, day: 11),
IsVerified = true,
PrivacyActivated = false,
UserGroup = UserGroup.Board,
UserState = UserState.Active
};

var purchase = new Purchase
{
Id = 1,
ProductId = product.Id,
ProductName = product.Name,
Price = product.Price,
NumberOfTickets = product.NumberOfTickets,
ExternalTransactionId = Guid.NewGuid().ToString(),
PurchasedBy = user,
OrderId = "test",
};
context.Add(user);
context.Add(product);
context.Add(purchase);
await context.SaveChangesAsync();

var mobilePayService = new Mock<IMobilePayPaymentsService>();
mobilePayService.Setup(mps => mps.RefundPayment(It.IsAny<Guid>()))
.ReturnsAsync(true);
var mailService = new Mock<Library.Services.IEmailService>();
var productService = new ProductService(context);
var ticketService = new TicketService(context, new Mock<IStatisticService>().Object);
var purchaseService = new PurchaseService(context, mobilePayService.Object, ticketService,
mailService.Object, productService);

// Act, Assert
await Assert.ThrowsAsync<EntityNotFoundException>(() => purchaseService.RefundPurchase(2));
}

[Theory(DisplayName = "RefundPurchase throws exception when purchase is already refunded")]
[MemberData(nameof(ProductGenerator))]
public async Task RefundPurchaseThrowsExceptionWhenAlreadyRefunded(Product product)
{
// Arrange
var builder = new DbContextOptionsBuilder<
CoffeeCardContext>()
.UseInMemoryDatabase(nameof(RefundPurchaseThrowsExceptionWhenAlreadyRefunded) +
product.Name);

var databaseSettings = new DatabaseSettings
{
SchemaName = "test"
};
var environmentSettings = new EnvironmentSettings()
{
EnvironmentType = EnvironmentType.Test
};

await using var context = new CoffeeCardContext(builder.Options, databaseSettings, environmentSettings);
var user = new User
{
Id = 1,
Name = "User1",
Email = "email@email.test",
Password = "password",
Salt = "salt",
DateCreated = new DateTime(year: 2020, month: 11, day: 11),
IsVerified = true,
PrivacyActivated = false,
UserGroup = UserGroup.Board,
UserState = UserState.Active
};

var purchase = new Purchase
{
Id = 1,
ProductId = product.Id,
ProductName = product.Name,
Price = product.Price,
NumberOfTickets = product.NumberOfTickets,
ExternalTransactionId = Guid.NewGuid().ToString(),
PurchasedBy = user,
OrderId = "test",
Status = PurchaseStatus.Refunded
};
context.Add(user);
context.Add(product);
context.Add(purchase);
await context.SaveChangesAsync();

var mobilePayService = new Mock<IMobilePayPaymentsService>();
mobilePayService.Setup(mps => mps.RefundPayment(It.IsAny<Guid>()))
.ReturnsAsync(true);
var mailService = new Mock<Library.Services.IEmailService>();
var productService = new ProductService(context);
var ticketService = new TicketService(context, new Mock<IStatisticService>().Object);
var purchaseService = new PurchaseService(context, mobilePayService.Object, ticketService,
mailService.Object, productService);

// Act, Assert
await Assert.ThrowsAsync<IllegalUserOperationException>(() => purchaseService.RefundPurchase(product.Id));
}

public static IEnumerable<object[]> ProductGenerator()
{
var pug = new List<ProductUserGroup> { new ProductUserGroup { ProductId = 1 } };
Expand Down
24 changes: 22 additions & 2 deletions coffeecard/CoffeeCard.WebApi/Controllers/v2/PurchasesController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,19 @@
using System.Threading.Tasks;
using CoffeeCard.Library.Services.v2;
using CoffeeCard.Library.Utils;
using CoffeeCard.MobilePay.Generated.Api.PaymentsApi;
using CoffeeCard.Models.DataTransferObjects;
using CoffeeCard.Models.DataTransferObjects.v2.Purchase;
using CoffeeCard.Models.Entities;
using CoffeeCard.WebApi.Helpers;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;

namespace CoffeeCard.WebApi.Controllers.v2
{
/// <summary>
/// Controller for initiating and retrieving purchases
/// Controller for initiating and retrieving purchases
/// </summary>
[ApiController]
[Authorize]
Expand Down Expand Up @@ -69,7 +72,7 @@ public async Task<ActionResult<SinglePurchaseResponse>> GetPurchase([FromRoute]
}

/// <summary>
/// Initiate a new payment.
/// Initiate a new payment.
/// </summary>
/// <param name="initiateRequest">Initiate request</param>
/// <returns>Purchase with payment details</returns>
Expand All @@ -88,5 +91,22 @@ public async Task<ActionResult<InitiatePurchaseResponse>> InitiatePurchase([From
// TODO Return CreatedAtAction
return Ok(purchaseResponse);
}


/// <summary>
/// Refunds a payment
/// </summary>
/// <param name="id">database id of purchase</param>
/// <returns>Purchase after being refunded</returns>
[HttpPut("{id}/refund")]
[AuthorizeRoles(UserGroup.Board)]
[ProducesResponseType(typeof(SimplePurchaseResponse), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(void), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(void), StatusCodes.Status403Forbidden)]
public async Task<ActionResult<bool>> RefundPurchase([FromRoute] int id)
{
var refundResponse = await _purchaseService.RefundPurchase(id);
return Ok(refundResponse);
}
}
}

0 comments on commit 6fc0181

Please sign in to comment.