add LiqPay integration for ticket purchase
All checks were successful
/ build (push) Successful in 5m8s
/ tests (push) Successful in 33s
/ build-docker (push) Successful in 4m15s

hosted services deletes ticket groups with reserved status that were created more than 10 minutes ago

payment link expires in 10 minutes from the time it was created
This commit is contained in:
cuqmbr 2025-05-26 12:16:46 +03:00
parent e3dd2dd582
commit afe626bd78
Signed by: cuqmbr
GPG Key ID: 0AA446880C766199
53 changed files with 2396 additions and 33 deletions

View File

@ -17,6 +17,7 @@
<PackageReference Include="MediatR" Version="12.4.1" />
<PackageReference Include="MediatR.Behaviors.Authorization" Version="12.2.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.4" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="QuikGraph" Version="2.5.0" />
<PackageReference Include="System.Linq.Dynamic.Core" Version="1.6.2" />
</ItemGroup>

View File

@ -0,0 +1,17 @@
using MediatR.Behaviors.Authorization;
namespace cuqmbr.TravelGuide.Application.Common.Authorization;
public class AllowAllRequirement : IAuthorizationRequirement
{
class MustBeAuthenticatedRequirementHandler :
IAuthorizationHandler<AllowAllRequirement>
{
public Task<AuthorizationResult> Handle(
AllowAllRequirement request,
CancellationToken cancellationToken)
{
return Task.FromResult(AuthorizationResult.Succeed());
}
}
}

View File

@ -1,4 +1,3 @@
// using cuqmbr.TravelGuide.Application.Common.Exceptions;
using MediatR.Behaviors.Authorization;
namespace cuqmbr.TravelGuide.Application.Common.Authorization;
@ -17,8 +16,6 @@ public class MustBeAuthenticatedRequirement : IAuthorizationRequirement
if (!request.IsAuthenticated)
{
return Task.FromResult(AuthorizationResult.Fail());
// TODO: Remove UnAuthorizedException, isn't used
// throw new UnAuthorizedException();
}
return Task.FromResult(AuthorizationResult.Succeed());

View File

@ -0,0 +1,13 @@
using cuqmbr.TravelGuide.Domain.Enums;
namespace cuqmbr.TravelGuide.Application.Common.Interfaces.Services;
public interface LiqPayPaymentService
{
Task<string> GetPaymentLinkAsync(
decimal amount, Currency currency,
string orderId, TimeSpan validity, string description,
string resultPath, string callbackPath);
Task<bool> IsValidSignatureAsync(string postData, string postSignature);
}

View File

@ -0,0 +1,25 @@
using cuqmbr.TravelGuide.Application.Payments.LiqPay.TicketGroups.Models;
using cuqmbr.TravelGuide.Domain.Enums;
using MediatR;
namespace cuqmbr.TravelGuide.Application.Payments.LiqPay
.TicketGroups.Commands.GetPaymentLink;
public record GetPaymentLinkCommand : IRequest<PaymentLinkDto>
{
public string PassangerFirstName { get; set; }
public string PassangerLastName { get; set; }
public string PassangerPatronymic { get; set; }
public Sex PassangerSex { get; set; }
public DateOnly PassangerBirthDate { get; set; }
public ICollection<TicketGroupPaymentTicketModel> Tickets { get; set; }
public string ResultPath { get; set; }
}

View File

@ -0,0 +1,32 @@
using cuqmbr.TravelGuide.Application.Common.Authorization;
using cuqmbr.TravelGuide.Application.Common.Interfaces.Services;
using cuqmbr.TravelGuide.Application.Common.Models;
using MediatR.Behaviors.Authorization;
namespace cuqmbr.TravelGuide.Application.Payments.LiqPay
.TicketGroups.Commands.GetPaymentLink;
public class GetPaymentLinkCommandAuthorizer :
AbstractRequestAuthorizer<GetPaymentLinkCommand>
{
private readonly SessionUserService _sessionUserService;
public GetPaymentLinkCommandAuthorizer(SessionUserService sessionUserService)
{
_sessionUserService = sessionUserService;
}
public override void BuildPolicy(GetPaymentLinkCommand request)
{
UseRequirement(new MustBeAuthenticatedRequirement
{
IsAuthenticated= _sessionUserService.IsAuthenticated
});
UseRequirement(new MustBeInRolesRequirement
{
RequiredRoles = [IdentityRole.Administrator],
UserRoles = _sessionUserService.Roles
});
}
}

View File

@ -0,0 +1,474 @@
using MediatR;
using cuqmbr.TravelGuide.Application.Common.Interfaces.Persistence;
using cuqmbr.TravelGuide.Domain.Entities;
using cuqmbr.TravelGuide.Application.Common.Exceptions;
using cuqmbr.TravelGuide.Application.Common.Interfaces.Services;
using cuqmbr.TravelGuide.Domain.Enums;
using FluentValidation.Results;
using Microsoft.Extensions.Localization;
namespace cuqmbr.TravelGuide.Application.Payments.LiqPay
.TicketGroups.Commands.GetPaymentLink;
public class GetPaymentLinkCommandHandler :
IRequestHandler<GetPaymentLinkCommand, PaymentLinkDto>
{
private readonly UnitOfWork _unitOfWork;
private readonly CurrencyConverterService _currencyConverterService;
private readonly LiqPayPaymentService _liqPayPaymentService;
private readonly IStringLocalizer _localizer;
public GetPaymentLinkCommandHandler(
UnitOfWork unitOfWork,
CurrencyConverterService currencyConverterService,
LiqPayPaymentService liqPayPaymentService,
IStringLocalizer localizer)
{
_unitOfWork = unitOfWork;
_currencyConverterService = currencyConverterService;
_liqPayPaymentService = liqPayPaymentService;
_localizer = localizer;
}
public async Task<PaymentLinkDto> Handle(
GetPaymentLinkCommand request,
CancellationToken cancellationToken)
{
// Check whether provided vehicle enrollments are present in datastore.
{
var vehicleEnrollmentGuids =
request.Tickets.Select(t => t.VehicleEnrollmentGuid);
var vehicleEnrollments = (await _unitOfWork.VehicleEnrollmentRepository
.GetPageAsync(
e => vehicleEnrollmentGuids.Contains(e.Guid),
1, vehicleEnrollmentGuids.Count(), cancellationToken))
.Items;
if (vehicleEnrollmentGuids.Count() > vehicleEnrollments.Count)
{
throw new NotFoundException();
}
}
// Check whether provided arrival and departure address guids
// are used in provided vehicle enrollment and
// and are in the correct order.
{
var vehicleEnrollmentGuids =
request.Tickets.Select(t => t.VehicleEnrollmentGuid);
var vehicleEnrollments = (await _unitOfWork.VehicleEnrollmentRepository
.GetPageAsync(
e => vehicleEnrollmentGuids.Contains(e.Guid),
1, vehicleEnrollmentGuids.Count(), cancellationToken))
.Items;
var routeAddressGuids =
request.Tickets.Select(t => t.DepartureRouteAddressGuid).Concat(
request.Tickets.Select(t => t.ArrivalRouteAddressGuid));
var routeAddresses = (await _unitOfWork.RouteAddressRepository
.GetPageAsync(
e =>
routeAddressGuids.Contains(e.Guid),
1, routeAddressGuids.Count(), cancellationToken))
.Items;
foreach (var t in request.Tickets)
{
var departureRouteAddress = routeAddresses.First(
ra => ra.Guid == t.DepartureRouteAddressGuid);
var arrivalRouteAddress = routeAddresses.First(
ra => ra.Guid == t.ArrivalRouteAddressGuid);
var ve = vehicleEnrollments.First(
e => e.Guid == t.VehicleEnrollmentGuid);
if (departureRouteAddress.RouteId != ve.RouteId ||
arrivalRouteAddress.RouteId != ve.RouteId)
{
throw new ValidationException(
new List<ValidationFailure>
{
new()
{
PropertyName = nameof(request.Tickets)
}
});
}
if (departureRouteAddress.Order > arrivalRouteAddress.Order)
{
throw new ValidationException(
new List<ValidationFailure>
{
new()
{
PropertyName = nameof(request.Tickets)
}
});
}
}
}
// Check availability of free places.
{
// Get all tickets for vehicle enrollments requested in ticket group.
var vehicleEnrollmentGuids =
request.Tickets.Select(t => t.VehicleEnrollmentGuid);
var unavailableTicketStatuses = new TicketStatus[]
{
TicketStatus.Reserved,
TicketStatus.Purchased
};
var ticketGroupTickets = (await _unitOfWork.TicketRepository
.GetPageAsync(
e =>
vehicleEnrollmentGuids.Contains(e.VehicleEnrollment.Guid) &&
unavailableTicketStatuses.Contains(e.TicketGroup.Status),
1, int.MaxValue, cancellationToken))
.Items;
// Get all vehicle enrollments requested in ticket group
// together with vehicles.
var vehicleEnrollments = (await _unitOfWork.VehicleEnrollmentRepository
.GetPageAsync(
e => vehicleEnrollmentGuids.Contains(e.Guid),
e => e.Vehicle,
1, vehicleEnrollmentGuids.Count(), cancellationToken))
.Items;
// Get all route addresses of vehicle enrollments
// requested in ticket group.
var routeIds = vehicleEnrollments.Select(e => e.RouteId);
var routeAddresses = (await _unitOfWork.RouteAddressRepository
.GetPageAsync(
e => routeIds.Contains(e.RouteId),
1, int.MaxValue, cancellationToken))
.Items;
// For each ticket in request.
foreach (var requestTicket in request.Tickets)
{
// Get vehicle enrollment of requested ticket.
var requestVehicleEnrollment = vehicleEnrollments.First(e =>
e.Guid == requestTicket.VehicleEnrollmentGuid);
// Get bought tickets of vehicle enrollment of requested ticket.
var tickets = ticketGroupTickets.Where(t =>
t.VehicleEnrollmentId == requestVehicleEnrollment.Id);
// Get route addresses of vehicle enrollment.
var ticketRouteAddresses = routeAddresses
.Where(e => e.RouteId == requestVehicleEnrollment.RouteId)
.OrderBy(e => e.Order);
// Count available capacity.
// Get total capacity in requested vehicle.
int totalCapacity;
var vehicle = vehicleEnrollments.First(e =>
e.Guid == requestTicket.VehicleEnrollmentGuid)
.Vehicle;
if (vehicle.VehicleType.Equals(VehicleType.Bus))
{
totalCapacity = ((Bus)vehicle).Capacity;
}
else if (vehicle.VehicleType.Equals(VehicleType.Aircraft))
{
totalCapacity = ((Aircraft)vehicle).Capacity;
}
else if (vehicle.VehicleType.Equals(VehicleType.Train))
{
totalCapacity = ((Train)vehicle).Capacity;
}
else
{
throw new NotImplementedException();
}
int takenCapacity = 0;
// For each bought ticket.
foreach (var ticket in tickets)
{
// Get departure and arrival route address
// of requested ticket.
var requestDepartureRouteAddress = ticketRouteAddresses
.Single(e =>
e.Guid == requestTicket.DepartureRouteAddressGuid);
var requestArrivalRouteAddress = ticketRouteAddresses
.Single(e =>
e.Guid == requestTicket.ArrivalRouteAddressGuid);
// Get departure and arrival route address
// of bought ticket.
var departureRouteAddress = ticketRouteAddresses
.Single(e =>
e.Id == ticket.DepartureRouteAddressId);
var arrivalRouteAddress = ticketRouteAddresses
.Single(e =>
e.Id == ticket.ArrivalRouteAddressId);
// Count taken capacity in requested vehicle
// accounting for requested ticket
// departure and arrival route addresses.
// The algorithm is the same as vehicle enrollment
// time overlap check.
if ((requestDepartureRouteAddress.Order >=
departureRouteAddress.Order &&
requestDepartureRouteAddress.Order <
arrivalRouteAddress.Order) ||
(requestArrivalRouteAddress.Order <=
arrivalRouteAddress.Order &&
requestArrivalRouteAddress.Order >
departureRouteAddress.Order) ||
(requestDepartureRouteAddress.Order <=
departureRouteAddress.Order &&
requestArrivalRouteAddress.Order >=
arrivalRouteAddress.Order))
{
takenCapacity++;
}
}
var availableCapacity = totalCapacity - takenCapacity;
if (availableCapacity <= 0)
{
throw new ValidationException(
new List<ValidationFailure>
{
new()
{
PropertyName = nameof(request.Tickets)
}
});
}
}
}
// Calculate travel time and cost.
var ticketsDetails = new List<(short order, DateTimeOffset departureTime,
DateTimeOffset arrivalTime, decimal cost, Currency currency)>();
{
var vehicleEnrollmentGuids =
request.Tickets.Select(t => t.VehicleEnrollmentGuid);
var vehicleEnrollments = (await _unitOfWork.VehicleEnrollmentRepository
.GetPageAsync(
e => vehicleEnrollmentGuids.Contains(e.Guid),
1, vehicleEnrollmentGuids.Count(), cancellationToken))
.Items;
var routeAddressGuids =
request.Tickets.Select(t => t.DepartureRouteAddressGuid).Concat(
request.Tickets.Select(t => t.ArrivalRouteAddressGuid));
var routeAddresses = (await _unitOfWork.RouteAddressRepository
.GetPageAsync(
e =>
routeAddressGuids.Contains(e.Guid),
1, routeAddressGuids.Count(), cancellationToken))
.Items;
var vehicleEnrollmentIds = vehicleEnrollments.Select(ve => ve.Id);
var allRouteAddressDetails = (await _unitOfWork
.RouteAddressDetailRepository.GetPageAsync(
e => vehicleEnrollmentIds.Contains(e.VehicleEnrollmentId),
e => e.RouteAddress,
1, int.MaxValue, cancellationToken))
.Items;
foreach (var t in request.Tickets.OrderBy(t => t.Order))
{
var ve = vehicleEnrollments.First(
e => e.Guid == t.VehicleEnrollmentGuid);
var departureRouteAddressId = routeAddresses.First(
ra => ra.Guid == t.DepartureRouteAddressGuid)
.Id;
var arrivalRouteAddressId = routeAddresses.First(
ra => ra.Guid == t.ArrivalRouteAddressGuid)
.Id;
var verad = allRouteAddressDetails
.Where(arad => arad.VehicleEnrollmentId == ve.Id)
.OrderBy(rad => rad.RouteAddress.Order)
.TakeWhile(rad => rad.Id != arrivalRouteAddressId);
// TODO: This counts departure address stop time which is
// not wrong but may be not desired.
var timeToDeparture = verad
.TakeWhile(rad => rad.Id != departureRouteAddressId)
.Aggregate(TimeSpan.Zero, (sum, next) =>
sum + next.TimeToNextAddress + next.CurrentAddressStopTime);
var departureTime = ve.DepartureTime.Add(timeToDeparture);
var timeToArrival = verad.Aggregate(TimeSpan.Zero, (sum, next) =>
sum + next.TimeToNextAddress + next.CurrentAddressStopTime);
var arrivalTime = ve.DepartureTime.Add(timeToArrival);
var costToDeparture = verad
.TakeWhile(rad => rad.Id != departureRouteAddressId)
.Aggregate((decimal)0, (sum, next) =>
sum + next.CostToNextAddress);
var costToArrival = verad
.Aggregate((decimal)0, (sum, next) =>
sum + next.CostToNextAddress);
var cost = costToArrival - costToDeparture;
ticketsDetails.Add(
(t.Order, departureTime, arrivalTime, cost, ve.Currency));
}
}
// Check whether there are overlaps in ticket departure/arrival times.
{
for (int i = 1; i < ticketsDetails.Count; i++)
{
var previousTd = ticketsDetails[i - 1];
var currentTd = ticketsDetails[i];
if (previousTd.arrivalTime >= currentTd.departureTime)
{
throw new ValidationException(
new List<ValidationFailure>
{
new()
{
PropertyName = nameof(request.Tickets)
}
});
}
}
}
// Create entity and insert into a datastore.
{
var vehicleEnrollmentGuids =
request.Tickets.Select(t => t.VehicleEnrollmentGuid);
var vehicleEnrollments = (await _unitOfWork.VehicleEnrollmentRepository
.GetPageAsync(
e => vehicleEnrollmentGuids.Contains(e.Guid),
1, vehicleEnrollmentGuids.Count(), cancellationToken))
.Items;
var routeAddressGuids =
request.Tickets.Select(t => t.DepartureRouteAddressGuid).Concat(
request.Tickets.Select(t => t.ArrivalRouteAddressGuid));
var routeAddresses = (await _unitOfWork.RouteAddressRepository
.GetPageAsync(
e => routeAddressGuids.Contains(e.Guid),
e => e.Address.City.Region.Country,
1, routeAddressGuids.Count(), cancellationToken))
.Items;
var travelTime =
ticketsDetails.OrderBy(td => td.order).Last().arrivalTime -
ticketsDetails.OrderBy(td => td.order).First().departureTime;
var entity = new TicketGroup()
{
PassangerFirstName = request.PassangerFirstName,
PassangerLastName = request.PassangerLastName,
PassangerPatronymic = request.PassangerPatronymic,
PassangerSex = request.PassangerSex,
PassangerBirthDate = request.PassangerBirthDate,
PurchaseTime = DateTimeOffset.UtcNow,
Status = TicketStatus.Reserved,
TravelTime = travelTime,
Tickets = request.Tickets.Select(
t =>
{
var ve = vehicleEnrollments.First(
ve => ve.Guid == t.VehicleEnrollmentGuid);
var departureRouteAddress = routeAddresses.First(
ra => ra.Guid == t.DepartureRouteAddressGuid);
var arrivalRouteAddress = routeAddresses.First(
ra => ra.Guid == t.ArrivalRouteAddressGuid);
var detail = ticketsDetails
.SingleOrDefault(td => td.order == t.Order);
var currency = Currency.UAH;
var cost = _currencyConverterService
.ConvertAsync(
detail.cost, detail.currency, currency,
cancellationToken).Result;
return new Ticket()
{
DepartureRouteAddressId = departureRouteAddress.Id,
DepartureRouteAddress = departureRouteAddress,
ArrivalRouteAddressId = arrivalRouteAddress.Id,
ArrivalRouteAddress = arrivalRouteAddress,
Order = t.Order,
Cost = cost,
Currency = currency,
VehicleEnrollmentId = ve.Id
};
})
.ToArray()
};
entity = await _unitOfWork.TicketGroupRepository.AddOneAsync(
entity, cancellationToken);
await _unitOfWork.SaveAsync(cancellationToken);
_unitOfWork.Dispose();
var amount = entity.Tickets.Sum(e => e.Cost);
var guid = entity.Guid;
var validity = TimeSpan.FromMinutes(10);
var resultPath = request.ResultPath;
var callbackPath = "/payments/liqPay/ticket/callback";
var paymentLink = await _liqPayPaymentService
.GetPaymentLinkAsync(
amount, Currency.UAH, guid.ToString(), validity,
_localizer["PaymentProcessing.TicketPaymentDescription"],
resultPath, callbackPath);
return new PaymentLinkDto() { PaymentLink = paymentLink };
}
}
}

View File

@ -0,0 +1,101 @@
using cuqmbr.TravelGuide.Application.Common.FluentValidation;
using cuqmbr.TravelGuide.Application.Common.Interfaces.Services;
using cuqmbr.TravelGuide.Domain.Enums;
using FluentValidation;
using Microsoft.Extensions.Localization;
namespace cuqmbr.TravelGuide.Application.Payments.LiqPay
.TicketGroups.Commands.GetPaymentLink;
public class GetPaymentLinkCommandValidator :
AbstractValidator<GetPaymentLinkCommand>
{
public GetPaymentLinkCommandValidator(
IStringLocalizer localizer,
SessionCultureService cultureService)
{
RuleFor(tg => tg.PassangerFirstName)
.NotEmpty()
.WithMessage(localizer["FluentValidation.NotEmpty"])
.MaximumLength(32)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.MaximumLength"],
32));
RuleFor(tg => tg.PassangerLastName)
.NotEmpty()
.WithMessage(localizer["FluentValidation.NotEmpty"])
.MaximumLength(32)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.MaximumLength"],
32));
RuleFor(tg => tg.PassangerPatronymic)
.NotEmpty()
.WithMessage(localizer["FluentValidation.NotEmpty"])
.MaximumLength(32)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.MaximumLength"],
32));
RuleFor(tg => tg.PassangerSex)
.Must((tg, s) => Sex.Enumerations.ContainsValue(s))
.WithMessage(
String.Format(
localizer["FluentValidation.MustBeInEnum"],
String.Join(
", ",
Sex.Enumerations.Values.Select(e => e.Name))));
RuleFor(tg => tg.PassangerBirthDate)
.GreaterThanOrEqualTo(DateOnly.FromDateTime(DateTime.UtcNow.AddYears(-100)))
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.GreaterThanOrEqualTo"],
DateOnly.FromDateTime(DateTime.UtcNow.AddYears(-100))));
RuleFor(tg => tg.Tickets)
.IsUnique(t => t.VehicleEnrollmentGuid)
.WithMessage(localizer["FluentValidation.IsUnique"]);
RuleFor(tg => tg.Tickets)
.IsUnique(t => t.Order)
.WithMessage(localizer["FluentValidation.IsUnique"]);
RuleForEach(tg => tg.Tickets).ChildRules(t =>
{
t.RuleFor(t => t.DepartureRouteAddressGuid)
.NotEmpty()
.WithMessage(localizer["FluentValidation.NotEmpty"]);
t.RuleFor(t => t.ArrivalRouteAddressGuid)
.NotEmpty()
.WithMessage(localizer["FluentValidation.NotEmpty"]);
t.RuleFor(t => t.Order)
.GreaterThanOrEqualTo(short.MinValue)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.GreaterThanOrEqualTo"],
short.MinValue))
.LessThanOrEqualTo(short.MaxValue)
.WithMessage(
String.Format(
cultureService.Culture,
localizer["FluentValidation.LessThanOrEqualTo"],
short.MaxValue));
t.RuleFor(t => t.VehicleEnrollmentGuid)
.NotEmpty()
.WithMessage(localizer["FluentValidation.NotEmpty"]);
});
}
}

View File

@ -0,0 +1,11 @@
using MediatR;
namespace cuqmbr.TravelGuide.Application.Payments.LiqPay
.TicketGroups.Commands.ProcessCallback;
public record ProcessCallbackCommand : IRequest
{
public string Data { get; set; }
public string Signature { get; set; }
}

View File

@ -0,0 +1,14 @@
using cuqmbr.TravelGuide.Application.Common.Authorization;
using MediatR.Behaviors.Authorization;
namespace cuqmbr.TravelGuide.Application.Payments.LiqPay
.TicketGroups.Commands.ProcessCallback;
public class ProcessCallbackCommandAuthorizer :
AbstractRequestAuthorizer<ProcessCallbackCommand>
{
public override void BuildPolicy(ProcessCallbackCommand request)
{
UseRequirement(new AllowAllRequirement());
}
}

View File

@ -0,0 +1,70 @@
using MediatR;
using cuqmbr.TravelGuide.Application.Common.Interfaces.Persistence;
using cuqmbr.TravelGuide.Application.Common.Interfaces.Services;
using cuqmbr.TravelGuide.Application.Common.Exceptions;
using System.Text;
using Newtonsoft.Json;
using cuqmbr.TravelGuide.Domain.Enums;
namespace cuqmbr.TravelGuide.Application.Payments.LiqPay
.TicketGroups.Commands.ProcessCallback;
public class ProcessCallbackCommandHandler :
IRequestHandler<ProcessCallbackCommand>
{
private readonly UnitOfWork _unitOfWork;
private readonly LiqPayPaymentService _liqPayPaymentService;
public ProcessCallbackCommandHandler(
UnitOfWork unitOfWork,
LiqPayPaymentService liqPayPaymentService)
{
_unitOfWork = unitOfWork;
_liqPayPaymentService = liqPayPaymentService;
}
public async Task Handle(
ProcessCallbackCommand request,
CancellationToken cancellationToken)
{
var isSignatureValid = await _liqPayPaymentService
.IsValidSignatureAsync(request.Data, request.Signature);
if (!isSignatureValid)
{
throw new ForbiddenException();
}
var dataBytes = Convert.FromBase64String(request.Data);
var dataJson = Encoding.UTF8.GetString(dataBytes);
var data = JsonConvert.DeserializeObject<dynamic>(dataJson);
string status = data.status;
var ticketGroupGuid = Guid.Parse((string)data.order_id);
var ticketGroup = await _unitOfWork.TicketGroupRepository
.GetOneAsync(e => e.Guid == ticketGroupGuid, cancellationToken);
if (ticketGroup == null ||
ticketGroup.Status == TicketStatus.Purchased)
{
throw new ForbiddenException();
}
if (status.Equals("error") || status.Equals("failure"))
{
await _unitOfWork.TicketGroupRepository
.DeleteOneAsync(ticketGroup, cancellationToken);
}
else if (status.Equals("success"))
{
ticketGroup.Status = TicketStatus.Purchased;
await _unitOfWork.TicketGroupRepository
.UpdateOneAsync(ticketGroup, cancellationToken);
}
await _unitOfWork.SaveAsync(cancellationToken);
_unitOfWork.Dispose();
}
}

View File

@ -0,0 +1,23 @@
using cuqmbr.TravelGuide.Application.Common.Interfaces.Services;
using FluentValidation;
using Microsoft.Extensions.Localization;
namespace cuqmbr.TravelGuide.Application.Payments.LiqPay
.TicketGroups.Commands.ProcessCallback;
public class ProcessCallbackCommandValidator :
AbstractValidator<ProcessCallbackCommand>
{
public ProcessCallbackCommandValidator(
IStringLocalizer localizer,
SessionCultureService cultureService)
{
RuleFor(v => v.Data)
.NotEmpty()
.WithMessage(localizer["FluentValidation.NotEmpty"]);
RuleFor(v => v.Signature)
.NotEmpty()
.WithMessage(localizer["FluentValidation.NotEmpty"]);
}
}

View File

@ -0,0 +1,14 @@
namespace cuqmbr.TravelGuide.Application.Payments.LiqPay
.TicketGroups.Models;
public sealed class TicketGroupPaymentTicketModel
{
public Guid DepartureRouteAddressGuid { get; set; }
public Guid ArrivalRouteAddressGuid { get; set; }
public short Order { get; set; }
public Guid VehicleEnrollmentGuid { get; set; }
}

View File

@ -0,0 +1,9 @@
namespace cuqmbr.TravelGuide.Application.Payments.LiqPay
.TicketGroups.ViewModels;
public sealed class CallbackViewModel
{
public string Data { get; set; }
public string Signature { get; set; }
}

View File

@ -0,0 +1,21 @@
namespace cuqmbr.TravelGuide.Application.Payments.LiqPay
.TicketGroups.ViewModels;
public sealed class TicketGroupPaymentViewModel
{
public string PassangerFirstName { get; set; }
public string PassangerLastName { get; set; }
public string PassangerPatronymic { get; set; }
public string PassangerSex { get; set; }
public DateOnly PassangerBirthDate { get; set; }
public ICollection<TicketPaymentViewModel> Tickets { get; set; }
public string ResultPath { get; set; }
}

View File

@ -0,0 +1,14 @@
namespace cuqmbr.TravelGuide.Application.Payments.LiqPay
.TicketGroups.ViewModels;
public sealed class TicketPaymentViewModel
{
public Guid DepartureRouteAddressUuid { get; set; }
public Guid ArrivalRouteAddressUuid { get; set; }
public short Order { get; set; }
public Guid VehicleEnrollmentUuid { get; set; }
}

View File

@ -0,0 +1,6 @@
namespace cuqmbr.TravelGuide.Application.Payments;
public sealed class PaymentLinkDto
{
public string PaymentLink { get; set; }
}

View File

@ -58,5 +58,8 @@
"Title": "One or more internal server errors occurred.",
"Detail": "Report this error to service's support team."
}
},
"PaymentProcessing": {
"TicketPaymentDescription": "Ticket purchase."
}
}

View File

@ -18,7 +18,7 @@ public record AddTicketGroupCommand : IRequest<TicketGroupDto>
public DateTimeOffset PurchaseTime { get; set; }
public bool Returned { get; set; }
public TicketStatus Status { get; set; }
public ICollection<TicketModel> Tickets { get; set; }

View File

@ -128,11 +128,17 @@ public class AddTicketGroupCommandHandler :
var vehicleEnrollmentGuids =
request.Tickets.Select(t => t.VehicleEnrollmentGuid);
var unavailableTicketStatuses = new TicketStatus[]
{
TicketStatus.Reserved,
TicketStatus.Purchased
};
var ticketGroupTickets = (await _unitOfWork.TicketRepository
.GetPageAsync(
e =>
vehicleEnrollmentGuids.Contains(e.VehicleEnrollment.Guid) &&
e.TicketGroup.Returned == false,
unavailableTicketStatuses.Contains(e.TicketGroup.Status),
1, int.MaxValue, cancellationToken))
.Items;
@ -431,7 +437,7 @@ public class AddTicketGroupCommandHandler :
PassangerSex = request.PassangerSex,
PassangerBirthDate = request.PassangerBirthDate,
PurchaseTime = request.PurchaseTime,
Returned = request.Returned,
Status = request.Status,
TravelTime = travelTime,
Tickets = request.Tickets.Select(
t =>

View File

@ -67,6 +67,15 @@ public class AddTicketGroupCommandValidator : AbstractValidator<AddTicketGroupCo
localizer["FluentValidation.GreaterThanOrEqualTo"],
DateTimeOffset.UtcNow));
RuleFor(tg => tg.Status)
.Must((tg, s) => TicketStatus.Enumerations.ContainsValue(s))
.WithMessage(
String.Format(
localizer["FluentValidation.MustBeInEnum"],
String.Join(
", ",
TicketStatus.Enumerations.Values.Select(e => e.Name))));
RuleFor(tg => tg.Tickets)
.IsUnique(t => t.VehicleEnrollmentGuid)
.WithMessage(localizer["FluentValidation.IsUnique"]);

View File

@ -0,0 +1,8 @@
using MediatR;
namespace cuqmbr.TravelGuide.Application.TicketGroups.Commands.RemoveOldReservedTicketGroups;
public record RemoveOldReservedTicketGroupsCommand : IRequest
{
public TimeSpan ReservedFor { get; set; }
}

View File

@ -0,0 +1,14 @@
using cuqmbr.TravelGuide.Application.Common.Authorization;
using MediatR.Behaviors.Authorization;
namespace cuqmbr.TravelGuide.Application.TicketGroups.Commands.RemoveOldReservedTicketGroups;
public class RemoveOldReservedTicketGroupsCommandAuthorizer :
AbstractRequestAuthorizer<RemoveOldReservedTicketGroupsCommand>
{
public override void BuildPolicy(
RemoveOldReservedTicketGroupsCommand request)
{
UseRequirement(new AllowAllRequirement());
}
}

View File

@ -0,0 +1,44 @@
using MediatR;
using cuqmbr.TravelGuide.Application.Common.Interfaces.Persistence;
using cuqmbr.TravelGuide.Domain.Enums;
namespace cuqmbr.TravelGuide.Application.TicketGroups
.Commands.RemoveOldReservedTicketGroups;
public class RemoveOldReservedTicketGroupsCommandHandler :
IRequestHandler<RemoveOldReservedTicketGroupsCommand>
{
private readonly UnitOfWork _unitOfWork;
public RemoveOldReservedTicketGroupsCommandHandler(UnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
public async Task Handle(
RemoveOldReservedTicketGroupsCommand request,
CancellationToken cancellationToken)
{
var statusesToRemove = new TicketStatus[]
{
TicketStatus.Reserved
};
var entities = (await _unitOfWork.TicketGroupRepository
.GetPageAsync(
e =>
statusesToRemove.Contains(e.Status) &&
DateTimeOffset.UtcNow - e.PurchaseTime > request.ReservedFor,
1, int.MaxValue, cancellationToken))
.Items;
foreach (var entity in entities)
{
await _unitOfWork.TicketGroupRepository
.DeleteOneAsync(entity, cancellationToken);
}
await _unitOfWork.SaveAsync(cancellationToken);
_unitOfWork.Dispose();
}
}

View File

@ -0,0 +1,20 @@
using FluentValidation;
using Microsoft.Extensions.Localization;
namespace cuqmbr.TravelGuide.Application.TicketGroups
.Commands.RemoveOldReservedTicketGroups;
public class RemoveOldReservedTicketGroupsCommandValidator :
AbstractValidator<RemoveOldReservedTicketGroupsCommand>
{
public RemoveOldReservedTicketGroupsCommandValidator(
IStringLocalizer localizer)
{
RuleFor(v => v.ReservedFor)
.GreaterThanOrEqualTo(TimeSpan.Zero)
.WithMessage(
String.Format(
localizer["FluentValidation.GreaterThanOrEqualTo"],
TimeSpan.Zero));
}
}

View File

@ -14,7 +14,7 @@ public sealed class AddTicketGroupViewModel
public DateTimeOffset PurchaseTime { get; set; }
public bool Returned { get; set; }
public string Status { get; set; }
public ICollection<TicketViewModel> Tickets { get; set; }

View File

@ -59,6 +59,12 @@
"Microsoft.Extensions.Options": "9.0.4"
}
},
"Newtonsoft.Json": {
"type": "Direct",
"requested": "[13.0.3, )",
"resolved": "13.0.3",
"contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
},
"QuikGraph": {
"type": "Direct",
"requested": "[2.5.0, )",

View File

@ -4,6 +4,8 @@ using PersistenceConfigurationOptions =
cuqmbr.TravelGuide.Persistence.ConfigurationOptions;
using ApplicationConfigurationOptions =
cuqmbr.TravelGuide.Application.ConfigurationOptions;
using InfrastructureConfigurationOptions =
cuqmbr.TravelGuide.Infrastructure.ConfigurationOptions;
using IdentityConfigurationOptions =
cuqmbr.TravelGuide.Identity.ConfigurationOptions;
@ -33,6 +35,10 @@ public static class Configuration
configuration.GetSection(
ApplicationConfigurationOptions.SectionName));
services.AddOptions<InfrastructureConfigurationOptions>().Bind(
configuration.GetSection(
InfrastructureConfigurationOptions.SectionName));
services.AddOptions<IdentityConfigurationOptions>().Bind(
configuration.GetSection(
IdentityConfigurationOptions.SectionName));

View File

@ -1,4 +1,5 @@
using cuqmbr.TravelGuide.Application.Common.Interfaces.Services;
using cuqmbr.TravelGuide.Infrastructure.Services;
using Microsoft.Extensions.DependencyInjection;
namespace cuqmbr.TravelGuide.Configuration.Infrastructure;
@ -14,7 +15,10 @@ public static class Configuration
services
.AddScoped<
CurrencyConverterService,
ExchangeApiCurrencyConverterService>();
ExchangeApiCurrencyConverterService>()
.AddScoped<
cuqmbr.TravelGuide.Application.Common.Interfaces.Services.LiqPayPaymentService,
cuqmbr.TravelGuide.Infrastructure.Services.LiqPayPaymentService>();
return services;
}

View File

@ -843,6 +843,7 @@
"MediatR": "[12.4.1, )",
"MediatR.Behaviors.Authorization": "[12.2.0, )",
"Microsoft.Extensions.Logging": "[9.0.4, )",
"Newtonsoft.Json": "[13.0.3, )",
"QuikGraph": "[2.5.0, )",
"System.Linq.Dynamic.Core": "[1.6.2, )"
}

View File

@ -16,7 +16,7 @@ public sealed class TicketGroup : EntityBase
public DateTimeOffset PurchaseTime { get; set; }
public bool Returned { get; set; }
public TicketStatus Status { get; set; }
public TimeSpan TravelTime { get; set; }

View File

@ -0,0 +1,25 @@
namespace cuqmbr.TravelGuide.Domain.Enums;
public abstract class TicketStatus : Enumeration<TicketStatus>
{
public static readonly TicketStatus Reserved = new ReservedTicketStatus();
public static readonly TicketStatus Returned = new ReturnedTicketStatus();
public static readonly TicketStatus Purchased = new PurchasedTicketStatus();
protected TicketStatus(int value, string name) : base(value, name) { }
private sealed class ReservedTicketStatus : TicketStatus
{
public ReservedTicketStatus() : base(0, "reserved") { }
}
private sealed class ReturnedTicketStatus : TicketStatus
{
public ReturnedTicketStatus() : base(1, "returned") { }
}
private sealed class PurchasedTicketStatus : TicketStatus
{
public PurchasedTicketStatus() : base(2, "purchased") { }
}
}

View File

@ -0,0 +1,92 @@
using Microsoft.AspNetCore.Mvc;
using Swashbuckle.AspNetCore.Annotations;
using cuqmbr.TravelGuide.Domain.Enums;
using cuqmbr.TravelGuide.Application.Payments;
using cuqmbr.TravelGuide.Application.Payments.LiqPay
.TicketGroups.Models;
using cuqmbr.TravelGuide.Application.Payments.LiqPay
.TicketGroups.ViewModels;
using cuqmbr.TravelGuide.Application.Payments.LiqPay
.TicketGroups.Commands.GetPaymentLink;
using cuqmbr.TravelGuide.Application.Payments.LiqPay
.TicketGroups.Commands.ProcessCallback;
namespace cuqmbr.TravelGuide.HttpApi.Controllers;
[Route("payments")]
public class PaymentController : ControllerBase
{
[HttpPost("liqPay/ticket/getLink")]
[SwaggerOperation("Get payment link for provided ticket")]
[SwaggerResponse(
StatusCodes.Status200OK, "Successfuly created",
typeof(PaymentLinkDto))]
[SwaggerResponse(
StatusCodes.Status400BadRequest, "Input data validation error",
typeof(HttpValidationProblemDetails))]
[SwaggerResponse(
StatusCodes.Status401Unauthorized, "Unauthorized to perform an action",
typeof(ProblemDetails))]
[SwaggerResponse(
StatusCodes.Status403Forbidden,
"Not enough privileges to perform an action",
typeof(ProblemDetails))]
[SwaggerResponse(
StatusCodes.Status500InternalServerError, "Internal server error",
typeof(ProblemDetails))]
public async Task<ActionResult<PaymentLinkDto>> LiqPayTicketGetLink(
[FromBody] TicketGroupPaymentViewModel viewModel,
CancellationToken cancellationToken)
{
return StatusCode(
StatusCodes.Status200OK,
await Mediator.Send(
new GetPaymentLinkCommand()
{
PassangerFirstName = viewModel.PassangerFirstName,
PassangerLastName = viewModel.PassangerLastName,
PassangerPatronymic = viewModel.PassangerPatronymic,
PassangerSex = Sex.FromName(viewModel.PassangerSex),
PassangerBirthDate = viewModel.PassangerBirthDate,
Tickets = viewModel.Tickets.Select(e =>
new TicketGroupPaymentTicketModel()
{
DepartureRouteAddressGuid = e.DepartureRouteAddressUuid,
ArrivalRouteAddressGuid = e.ArrivalRouteAddressUuid,
Order = e.Order,
VehicleEnrollmentGuid = e.VehicleEnrollmentUuid
})
.ToArray(),
ResultPath = viewModel.ResultPath
},
cancellationToken));
}
[Consumes("application/x-www-form-urlencoded")]
[HttpPost("liqPay/ticket/callback")]
[SwaggerOperation("Process LiqPay callback for ticket")]
[SwaggerResponse(
StatusCodes.Status200OK, "Successfuly processed")]
[SwaggerResponse(
StatusCodes.Status400BadRequest, "Input data validation error",
typeof(HttpValidationProblemDetails))]
[SwaggerResponse(
StatusCodes.Status403Forbidden,
"Not enough privileges to perform an action",
typeof(ProblemDetails))]
[SwaggerResponse(
StatusCodes.Status500InternalServerError, "Internal server error",
typeof(ProblemDetails))]
public async Task LiqPayTicketCallback(
[FromForm] CallbackViewModel viewModel,
CancellationToken cancellationToken)
{
await Mediator.Send(
new ProcessCallbackCommand()
{
Data = viewModel.Data,
Signature = viewModel.Signature
},
cancellationToken);
}
}

View File

@ -1,14 +1,8 @@
using Microsoft.AspNetCore.Mvc;
using Swashbuckle.AspNetCore.Annotations;
using cuqmbr.TravelGuide.Application.Common.Models;
using cuqmbr.TravelGuide.Application.Common.ViewModels;
using cuqmbr.TravelGuide.Domain.Enums;
using cuqmbr.TravelGuide.Application.TicketGroups;
using cuqmbr.TravelGuide.Application.TicketGroups.Commands.AddTicketGroup;
// using cuqmbr.TravelGuide.Application.TicketGroups.Queries.GetTicketGroupsPage;
// using cuqmbr.TravelGuide.Application.TicketGroups.Queries.GetTicketGroup;
// using cuqmbr.TravelGuide.Application.TicketGroups.Commands.UpdateTicketGroup;
// using cuqmbr.TravelGuide.Application.TicketGroups.Commands.DeleteTicketGroup;
using cuqmbr.TravelGuide.Application.TicketGroups.ViewModels;
using cuqmbr.TravelGuide.Application.TicketGroups.Models;
@ -56,7 +50,7 @@ public class TicketGroupsController : ControllerBase
PassangerSex = Sex.FromName(viewModel.PassangerSex),
PassangerBirthDate = viewModel.PassangerBirthDate,
PurchaseTime = viewModel.PurchaseTime,
Returned = viewModel.Returned,
Status = TicketStatus.FromName(viewModel.Status),
Tickets = viewModel.Tickets.Select(e =>
new TicketModel()
{

View File

@ -0,0 +1,41 @@
using MediatR;
using cuqmbr.TravelGuide.Application.TicketGroups
.Commands.RemoveOldReservedTicketGroups;
namespace cuqmbr.TravelGuide.HttpApi.HostedServices;
public class ReservedTicketRemoverHostedService : BackgroundService
{
private Timer _timer = null;
public ReservedTicketRemoverHostedService(
IServiceProvider serviceProvider)
{
ServiceProvider = serviceProvider;
}
public IServiceProvider ServiceProvider;
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
_timer = new Timer(async (state) =>
{
using var scope = ServiceProvider.CreateScope();
var _mediator = scope.ServiceProvider
.GetRequiredService<IMediator>();
await _mediator.Send(
new RemoveOldReservedTicketGroupsCommand()
{
ReservedFor = TimeSpan.FromMinutes(10)
}, cancellationToken);
},
null, TimeSpan.Zero, TimeSpan.FromMinutes(1));
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
_timer?.Dispose();
await base.StopAsync(cancellationToken);
}
}

View File

@ -6,6 +6,7 @@ using cuqmbr.TravelGuide.Configuration.Configuration;
using cuqmbr.TravelGuide.Configuration.Logging;
using cuqmbr.TravelGuide.Application.Common.Interfaces.Services;
using cuqmbr.TravelGuide.HttpApi.Services;
using cuqmbr.TravelGuide.HttpApi.HostedServices;
using cuqmbr.TravelGuide.HttpApi.Middlewares;
using cuqmbr.TravelGuide.HttpApi.Swashbuckle.OperationFilters;
using System.Net;
@ -33,6 +34,8 @@ services.AddScoped<SessionCultureService, AspNetSessionCultureService>();
services.AddScoped<SessionTimeZoneService, AspNetSessionTimeZoneService>();
services.AddScoped<SessionCurrencyService, AspNetSessionCurrencyService>();
services.AddHostedService<ReservedTicketRemoverHostedService>();
services.AddControllers();

View File

@ -13,6 +13,16 @@
"Localization": {
"DefaultCultureName": "en-US",
"CacheDuration": "00:30:00"
},
"Infrastructure": {
"PaymentProcessing": {
"CallbackAddressBase": "https://api.travel-guide.cuqmbr.xyz",
"ResultAddressBase": "https://travel-guide.cuqmbr.xyz",
"LiqPay": {
"PublicKey": "sandbox_xxxxxxxxxxxx",
"PrivateKey": "sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
}
}
},
"Identity": {

View File

@ -13,6 +13,16 @@
"Localization": {
"DefaultCultureName": "en-US",
"CacheDuration": "00:30:00"
},
"Infrastructure": {
"PaymentProcessing": {
"CallbackAddressBase": "https://api.travel-guide.cuqmbr.xyz",
"ResultAddressBase": "https://travel-guide.cuqmbr.xyz",
"LiqPay": {
"PublicKey": "sandbox_xxxxxxxxxxxx",
"PrivateKey": "sandbox_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
}
}
},
"Identity": {

View File

@ -1084,6 +1084,7 @@
"MediatR": "[12.4.1, )",
"MediatR.Behaviors.Authorization": "[12.2.0, )",
"Microsoft.Extensions.Logging": "[9.0.4, )",
"Newtonsoft.Json": "[13.0.3, )",
"QuikGraph": "[2.5.0, )",
"System.Linq.Dynamic.Core": "[1.6.2, )"
}

View File

@ -520,6 +520,11 @@
"System.Security.Principal.Windows": "4.5.0"
}
},
"Newtonsoft.Json": {
"type": "Transitive",
"resolved": "13.0.3",
"contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
},
"Npgsql": {
"type": "Transitive",
"resolved": "9.0.3",
@ -594,6 +599,7 @@
"MediatR": "[12.4.1, )",
"MediatR.Behaviors.Authorization": "[12.2.0, )",
"Microsoft.Extensions.Logging": "[9.0.4, )",
"Newtonsoft.Json": "[13.0.3, )",
"QuikGraph": "[2.5.0, )",
"System.Linq.Dynamic.Core": "[1.6.2, )"
}

View File

@ -2,5 +2,23 @@ namespace cuqmbr.TravelGuide.Infrastructure;
public sealed class ConfigurationOptions
{
public static string SectionName { get; } = "Infrastructure";
public static string SectionName { get; } = "Application:Infrastructure";
public PaymentProcessingConfigurationOptions PaymentProcessing { get; set; }
}
public sealed class PaymentProcessingConfigurationOptions
{
public string CallbackAddressBase { get; set; }
public string ResultAddressBase { get; set; }
public LiqPayConfigurationOptions LiqPay { get; set; }
}
public sealed class LiqPayConfigurationOptions
{
public string PublicKey { get; set; }
public string PrivateKey { get; set; }
}

View File

@ -3,6 +3,8 @@ using cuqmbr.TravelGuide.Application.Common.Interfaces.Services;
using cuqmbr.TravelGuide.Domain.Enums;
using Newtonsoft.Json;
namespace cuqmbr.TravelGuide.Infrastructure.Services;
// https://github.com/fawazahmed0/exchange-api
public sealed class ExchangeApiCurrencyConverterService :

View File

@ -0,0 +1,78 @@
using System.Dynamic;
using System.Security.Cryptography;
using System.Text;
using cuqmbr.TravelGuide.Domain.Enums;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
namespace cuqmbr.TravelGuide.Infrastructure.Services;
public sealed class LiqPayPaymentService :
cuqmbr.TravelGuide.Application.Common.Interfaces.Services.LiqPayPaymentService
{
private readonly LiqPayConfigurationOptions _configuration;
private readonly string _callbackAddressBase;
private readonly string _resultAddressBase;
private readonly IHttpClientFactory _httpClientFactory;
public LiqPayPaymentService(
IOptions<ConfigurationOptions> configurationOptions,
IHttpClientFactory httpClientFactory)
{
_configuration = configurationOptions.Value.PaymentProcessing.LiqPay;
_callbackAddressBase =
configurationOptions.Value.PaymentProcessing.CallbackAddressBase;
_resultAddressBase =
configurationOptions.Value.PaymentProcessing.ResultAddressBase;
}
public Task<string> GetPaymentLinkAsync(
decimal amount, Currency currency,
string orderId, TimeSpan validity, string description,
string resultPath, string callbackPath)
{
dynamic request = new ExpandoObject();
request.version = 3;
request.public_key = _configuration.PublicKey;
request.action = "pay";
request.amount = amount;
request.currency = currency.Name.ToUpper();
request.description = description;
request.order_id = orderId;
request.expired_date = DateTimeOffset.UtcNow.Add(validity)
.ToString("yyyy-MM-dd HH:mm:ss");
request.result_url = $"{_resultAddressBase}{resultPath}";
request.server_url = $"{_callbackAddressBase}{callbackPath}";
var requestJsonString = (string)JsonConvert.SerializeObject(request);
var requestJsonStringBytes = Encoding.UTF8.GetBytes(requestJsonString);
var data = Convert.ToBase64String(requestJsonStringBytes);
var signature = Convert.ToBase64String(SHA1.HashData(
Encoding.UTF8.GetBytes(
_configuration.PrivateKey +
data +
_configuration.PrivateKey)));
return Task.FromResult(
"https://www.liqpay.ua/api/3/checkout" +
$"?data={data}&signature={signature}");
}
public Task<bool> IsValidSignatureAsync(string postData, string postSignature)
{
var signature = Convert.ToBase64String(SHA1.HashData(
Encoding.UTF8.GetBytes(
_configuration.PrivateKey +
postData +
_configuration.PrivateKey)));
return Task.FromResult(postSignature.Equals(signature));
}
}

View File

@ -254,6 +254,7 @@
"MediatR": "[12.4.1, )",
"MediatR.Behaviors.Authorization": "[12.2.0, )",
"Microsoft.Extensions.Logging": "[9.0.4, )",
"Newtonsoft.Json": "[13.0.3, )",
"QuikGraph": "[2.5.0, )",
"System.Linq.Dynamic.Core": "[1.6.2, )"
}

View File

@ -47,6 +47,11 @@ public class InMemoryDbContext : DbContext
.HaveColumnType("varchar(32)")
.HaveConversion<SexConverter>();
builder
.Properties<TicketStatus>()
.HaveColumnType("varchar(32)")
.HaveConversion<TicketStatusConverter>();
builder
.Properties<DateTimeOffset>()
.HaveConversion<DateTimeOffsetConverter>();

View File

@ -14,10 +14,17 @@ public class TicketGroupConfiguration : BaseConfiguration<TicketGroup>
.HasColumnName("passanger_sex")
.IsRequired(true);
builder
.Property(tg => tg.Status)
.HasColumnName("status")
.IsRequired(true);
builder
.ToTable(
"ticket_groups",
tg => tg.HasCheckConstraint(
tg =>
{
tg.HasCheckConstraint(
"ck_" +
$"{builder.Metadata.GetTableName()}_" +
$"{builder.Property(tg => tg.PassangerSex)
@ -25,7 +32,18 @@ public class TicketGroupConfiguration : BaseConfiguration<TicketGroup>
$"{builder.Property(g => g.PassangerSex)
.Metadata.GetColumnName()} IN ('{String
.Join("', '", Sex.Enumerations
.Values.Select(v => v.Name))}')"));
.Values.Select(v => v.Name))}')");
tg.HasCheckConstraint(
"ck_" +
$"{builder.Metadata.GetTableName()}_" +
$"{builder.Property(tg => tg.Status)
.Metadata.GetColumnName()}",
$"{builder.Property(g => g.Status)
.Metadata.GetColumnName()} IN ('{String
.Join("', '", TicketStatus.Enumerations
.Values.Select(v => v.Name))}')");
});
base.Configure(builder);
@ -60,12 +78,6 @@ public class TicketGroupConfiguration : BaseConfiguration<TicketGroup>
.HasColumnType("timestamptz")
.IsRequired(true);
builder
.Property(a => a.Returned)
.HasColumnName("returned")
.HasColumnType("boolean")
.IsRequired(true);
builder
.Property(a => a.TravelTime)
.HasColumnName("travel_time")

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Persistence.PostgreSql.Migrations
{
/// <inheritdoc />
public partial class Add_status_to_Ticket_Group : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "returned",
schema: "application",
table: "ticket_groups");
migrationBuilder.AddColumn<string>(
name: "status",
schema: "application",
table: "ticket_groups",
type: "varchar(32)",
nullable: false,
defaultValue: "");
migrationBuilder.AddCheckConstraint(
name: "ck_ticket_groups_status",
schema: "application",
table: "ticket_groups",
sql: "status IN ('reserved', 'returned', 'purchased')");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropCheckConstraint(
name: "ck_ticket_groups_status",
schema: "application",
table: "ticket_groups");
migrationBuilder.DropColumn(
name: "status",
schema: "application",
table: "ticket_groups");
migrationBuilder.AddColumn<bool>(
name: "returned",
schema: "application",
table: "ticket_groups",
type: "boolean",
nullable: false,
defaultValue: false);
}
}
}

View File

@ -577,9 +577,10 @@ namespace Persistence.PostgreSql.Migrations
.HasColumnType("timestamptz")
.HasColumnName("purchase_time");
b.Property<bool>("Returned")
.HasColumnType("boolean")
.HasColumnName("returned");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("varchar(32)")
.HasColumnName("status");
b.Property<TimeSpan>("TravelTime")
.HasColumnType("interval")
@ -594,6 +595,8 @@ namespace Persistence.PostgreSql.Migrations
b.ToTable("ticket_groups", "application", t =>
{
t.HasCheckConstraint("ck_ticket_groups_passanger_sex", "passanger_sex IN ('male', 'female')");
t.HasCheckConstraint("ck_ticket_groups_status", "status IN ('reserved', 'returned', 'purchased')");
});
});

View File

@ -54,6 +54,11 @@ public class PostgreSqlDbContext : DbContext
.HaveColumnType("varchar(32)")
.HaveConversion<SexConverter>();
builder
.Properties<TicketStatus>()
.HaveColumnType("varchar(32)")
.HaveConversion<TicketStatusConverter>();
builder
.Properties<DateTimeOffset>()
.HaveConversion<DateTimeOffsetConverter>();

View File

@ -0,0 +1,13 @@
using cuqmbr.TravelGuide.Domain.Enums;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace cuqmbr.TravelGuide.Persistence.TypeConverters;
public class TicketStatusConverter : ValueConverter<TicketStatus, string>
{
public TicketStatusConverter()
: base(
v => v.Name,
v => TicketStatus.FromName(v))
{ }
}

View File

@ -266,6 +266,11 @@
"resolved": "9.0.4",
"contentHash": "SPFyMjyku1nqTFFJ928JAMd0QnRe4xjE7KeKnZMWXf3xk+6e0WiOZAluYtLdbJUXtsl2cCRSi8cBquJ408k8RA=="
},
"Newtonsoft.Json": {
"type": "Transitive",
"resolved": "13.0.3",
"contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
},
"Npgsql": {
"type": "Transitive",
"resolved": "9.0.3",
@ -334,6 +339,7 @@
"MediatR": "[12.4.1, )",
"MediatR.Behaviors.Authorization": "[12.2.0, )",
"Microsoft.Extensions.Logging": "[9.0.4, )",
"Newtonsoft.Json": "[13.0.3, )",
"QuikGraph": "[2.5.0, )",
"System.Linq.Dynamic.Core": "[1.6.2, )"
}

View File

@ -987,6 +987,7 @@
"MediatR": "[12.4.1, )",
"MediatR.Behaviors.Authorization": "[12.2.0, )",
"Microsoft.Extensions.Logging": "[9.0.4, )",
"Newtonsoft.Json": "[13.0.3, )",
"QuikGraph": "[2.5.0, )",
"System.Linq.Dynamic.Core": "[1.6.2, )"
}