add LiqPay integration for ticket purchase
All checks were successful
/ tests (push) Successful in 34s
/ build (push) Successful in 6m51s
/ build-docker (push) Successful in 4m23s

This commit is contained in:
cuqmbr 2025-05-25 21:34:36 +03:00
parent e3dd2dd582
commit 4c8ca2e14f
Signed by: cuqmbr
GPG Key ID: 0AA446880C766199
47 changed files with 2265 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,69 @@
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.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

@ -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

@ -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.expire_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,18 +14,36 @@ 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(
"ck_" +
$"{builder.Metadata.GetTableName()}_" +
$"{builder.Property(tg => tg.PassangerSex)
tg =>
{
tg.HasCheckConstraint(
"ck_" +
$"{builder.Metadata.GetTableName()}_" +
$"{builder.Property(tg => tg.PassangerSex)
.Metadata.GetColumnName()}",
$"{builder.Property(g => g.PassangerSex)
$"{builder.Property(g => g.PassangerSex)
.Metadata.GetColumnName()} IN ('{String
.Join("', '", Sex.Enumerations
.Values.Select(v => v.Name))}')"));
.Join("', '", Sex.Enumerations
.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, )"
}