From 4c8ca2e14fe8f809dc5e2938aa182a8df6dd898a Mon Sep 17 00:00:00 2001 From: cuqmbr Date: Sun, 25 May 2025 21:34:36 +0300 Subject: [PATCH] add LiqPay integration for ticket purchase --- src/Application/Application.csproj | 1 + .../Authorization/AllowAllRequirement.cs | 17 + .../MustBeAuthenticatedRequirement.cs | 3 - .../Services/LiqPayPaymentService.cs | 13 + .../GetPaymentLink/GetPaymentLinkCommand.cs | 25 + .../GetPaymentLinkCommandAuthorizer.cs | 32 + .../GetPaymentLinkCommandHandler.cs | 474 ++++++++ .../GetPaymentLinkCommandValidator.cs | 101 ++ .../ProcessCallback/ProcessCallbackCommand.cs | 11 + .../ProcessCallbackCommandAuthorizer.cs | 14 + .../ProcessCallbackCommandHandler.cs | 69 ++ .../ProcessCallbackCommandValidator.cs | 23 + .../Models/TicketGroupPaymentTicketModel.cs | 14 + .../ViewModels/CallbackViewModel.cs | 9 + .../ViewModels/TicketGroupPaymentViewModel.cs | 21 + .../ViewModels/TicketPaymentViewModel.cs | 14 + src/Application/Payments/PaymentLinkDto.cs | 6 + .../Resources/Localization/en-US.json | 3 + .../AddTicketGroup/AddTicketGroupCommand.cs | 2 +- .../AddTicketGroupCommandHandler.cs | 10 +- .../AddTicketGroupCommandValidator.cs | 9 + .../ViewModels/AddTicketGroupViewModel.cs | 2 +- src/Application/packages.lock.json | 6 + .../Configuration/Configuration.cs | 6 + .../Infrastructure/Configuration.cs | 6 +- src/Configuration/packages.lock.json | 1 + src/Domain/Entities/TicketGroup.cs | 2 +- src/Domain/Enums/TicketStatus.cs | 25 + src/HttpApi/Controllers/PaymentController.cs | 92 ++ .../Controllers/TicketGroupsController.cs | 8 +- src/HttpApi/appsettings.Development.json | 10 + src/HttpApi/appsettings.json | 10 + src/HttpApi/packages.lock.json | 1 + src/Identity/packages.lock.json | 6 + src/Infrastructure/ConfigurationOptions.cs | 20 +- .../ExchangeApiCurrencyConverterService.cs | 2 + .../Services/LiqPayPaymentService.cs | 78 ++ src/Infrastructure/packages.lock.json | 1 + src/Persistence/InMemory/InMemoryDbContext.cs | 5 + .../TicketGroupConfiguration.cs | 38 +- ...743_Add_status_to_Ticket_Group.Designer.cs | 1019 +++++++++++++++++ ...250524184743_Add_status_to_Ticket_Group.cs | 55 + .../PostgreSqlDbContextModelSnapshot.cs | 9 +- .../PostgreSql/PostgreSqlDbContext.cs | 5 + .../TypeConverters/TicketStatusConverter.cs | 13 + src/Persistence/packages.lock.json | 6 + .../packages.lock.json | 1 + 47 files changed, 2265 insertions(+), 33 deletions(-) create mode 100644 src/Application/Common/Authorization/AllowAllRequirement.cs create mode 100644 src/Application/Common/Interfaces/Services/LiqPayPaymentService.cs create mode 100644 src/Application/Payments/LiqPay/TicketGroups/Commands/GetPaymentLink/GetPaymentLinkCommand.cs create mode 100644 src/Application/Payments/LiqPay/TicketGroups/Commands/GetPaymentLink/GetPaymentLinkCommandAuthorizer.cs create mode 100644 src/Application/Payments/LiqPay/TicketGroups/Commands/GetPaymentLink/GetPaymentLinkCommandHandler.cs create mode 100644 src/Application/Payments/LiqPay/TicketGroups/Commands/GetPaymentLink/GetPaymentLinkCommandValidator.cs create mode 100644 src/Application/Payments/LiqPay/TicketGroups/Commands/ProcessCallback/ProcessCallbackCommand.cs create mode 100644 src/Application/Payments/LiqPay/TicketGroups/Commands/ProcessCallback/ProcessCallbackCommandAuthorizer.cs create mode 100644 src/Application/Payments/LiqPay/TicketGroups/Commands/ProcessCallback/ProcessCallbackCommandHandler.cs create mode 100644 src/Application/Payments/LiqPay/TicketGroups/Commands/ProcessCallback/ProcessCallbackCommandValidator.cs create mode 100644 src/Application/Payments/LiqPay/TicketGroups/Models/TicketGroupPaymentTicketModel.cs create mode 100644 src/Application/Payments/LiqPay/TicketGroups/ViewModels/CallbackViewModel.cs create mode 100644 src/Application/Payments/LiqPay/TicketGroups/ViewModels/TicketGroupPaymentViewModel.cs create mode 100644 src/Application/Payments/LiqPay/TicketGroups/ViewModels/TicketPaymentViewModel.cs create mode 100644 src/Application/Payments/PaymentLinkDto.cs create mode 100644 src/Domain/Enums/TicketStatus.cs create mode 100644 src/HttpApi/Controllers/PaymentController.cs create mode 100644 src/Infrastructure/Services/LiqPayPaymentService.cs create mode 100644 src/Persistence/PostgreSql/Migrations/20250524184743_Add_status_to_Ticket_Group.Designer.cs create mode 100644 src/Persistence/PostgreSql/Migrations/20250524184743_Add_status_to_Ticket_Group.cs create mode 100644 src/Persistence/TypeConverters/TicketStatusConverter.cs diff --git a/src/Application/Application.csproj b/src/Application/Application.csproj index 9b7a24d..0e77ba6 100644 --- a/src/Application/Application.csproj +++ b/src/Application/Application.csproj @@ -17,6 +17,7 @@ + diff --git a/src/Application/Common/Authorization/AllowAllRequirement.cs b/src/Application/Common/Authorization/AllowAllRequirement.cs new file mode 100644 index 0000000..05c7d78 --- /dev/null +++ b/src/Application/Common/Authorization/AllowAllRequirement.cs @@ -0,0 +1,17 @@ +using MediatR.Behaviors.Authorization; + +namespace cuqmbr.TravelGuide.Application.Common.Authorization; + +public class AllowAllRequirement : IAuthorizationRequirement +{ + class MustBeAuthenticatedRequirementHandler : + IAuthorizationHandler + { + public Task Handle( + AllowAllRequirement request, + CancellationToken cancellationToken) + { + return Task.FromResult(AuthorizationResult.Succeed()); + } + } +} diff --git a/src/Application/Common/Authorization/MustBeAuthenticatedRequirement.cs b/src/Application/Common/Authorization/MustBeAuthenticatedRequirement.cs index 809c638..685e587 100644 --- a/src/Application/Common/Authorization/MustBeAuthenticatedRequirement.cs +++ b/src/Application/Common/Authorization/MustBeAuthenticatedRequirement.cs @@ -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()); diff --git a/src/Application/Common/Interfaces/Services/LiqPayPaymentService.cs b/src/Application/Common/Interfaces/Services/LiqPayPaymentService.cs new file mode 100644 index 0000000..4a0551f --- /dev/null +++ b/src/Application/Common/Interfaces/Services/LiqPayPaymentService.cs @@ -0,0 +1,13 @@ +using cuqmbr.TravelGuide.Domain.Enums; + +namespace cuqmbr.TravelGuide.Application.Common.Interfaces.Services; + +public interface LiqPayPaymentService +{ + Task GetPaymentLinkAsync( + decimal amount, Currency currency, + string orderId, TimeSpan validity, string description, + string resultPath, string callbackPath); + + Task IsValidSignatureAsync(string postData, string postSignature); +} diff --git a/src/Application/Payments/LiqPay/TicketGroups/Commands/GetPaymentLink/GetPaymentLinkCommand.cs b/src/Application/Payments/LiqPay/TicketGroups/Commands/GetPaymentLink/GetPaymentLinkCommand.cs new file mode 100644 index 0000000..98f3956 --- /dev/null +++ b/src/Application/Payments/LiqPay/TicketGroups/Commands/GetPaymentLink/GetPaymentLinkCommand.cs @@ -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 +{ + 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 Tickets { get; set; } + + + public string ResultPath { get; set; } +} diff --git a/src/Application/Payments/LiqPay/TicketGroups/Commands/GetPaymentLink/GetPaymentLinkCommandAuthorizer.cs b/src/Application/Payments/LiqPay/TicketGroups/Commands/GetPaymentLink/GetPaymentLinkCommandAuthorizer.cs new file mode 100644 index 0000000..a1ad297 --- /dev/null +++ b/src/Application/Payments/LiqPay/TicketGroups/Commands/GetPaymentLink/GetPaymentLinkCommandAuthorizer.cs @@ -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 +{ + 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 + }); + } +} diff --git a/src/Application/Payments/LiqPay/TicketGroups/Commands/GetPaymentLink/GetPaymentLinkCommandHandler.cs b/src/Application/Payments/LiqPay/TicketGroups/Commands/GetPaymentLink/GetPaymentLinkCommandHandler.cs new file mode 100644 index 0000000..28c9328 --- /dev/null +++ b/src/Application/Payments/LiqPay/TicketGroups/Commands/GetPaymentLink/GetPaymentLinkCommandHandler.cs @@ -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 +{ + 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 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 + { + new() + { + PropertyName = nameof(request.Tickets) + } + }); + } + + if (departureRouteAddress.Order > arrivalRouteAddress.Order) + { + throw new ValidationException( + new List + { + 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 + { + 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 + { + 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 }; + } + } +} diff --git a/src/Application/Payments/LiqPay/TicketGroups/Commands/GetPaymentLink/GetPaymentLinkCommandValidator.cs b/src/Application/Payments/LiqPay/TicketGroups/Commands/GetPaymentLink/GetPaymentLinkCommandValidator.cs new file mode 100644 index 0000000..7fbc8eb --- /dev/null +++ b/src/Application/Payments/LiqPay/TicketGroups/Commands/GetPaymentLink/GetPaymentLinkCommandValidator.cs @@ -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 +{ + 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"]); + }); + } +} diff --git a/src/Application/Payments/LiqPay/TicketGroups/Commands/ProcessCallback/ProcessCallbackCommand.cs b/src/Application/Payments/LiqPay/TicketGroups/Commands/ProcessCallback/ProcessCallbackCommand.cs new file mode 100644 index 0000000..60b6068 --- /dev/null +++ b/src/Application/Payments/LiqPay/TicketGroups/Commands/ProcessCallback/ProcessCallbackCommand.cs @@ -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; } +} diff --git a/src/Application/Payments/LiqPay/TicketGroups/Commands/ProcessCallback/ProcessCallbackCommandAuthorizer.cs b/src/Application/Payments/LiqPay/TicketGroups/Commands/ProcessCallback/ProcessCallbackCommandAuthorizer.cs new file mode 100644 index 0000000..200d9db --- /dev/null +++ b/src/Application/Payments/LiqPay/TicketGroups/Commands/ProcessCallback/ProcessCallbackCommandAuthorizer.cs @@ -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 +{ + public override void BuildPolicy(ProcessCallbackCommand request) + { + UseRequirement(new AllowAllRequirement()); + } +} diff --git a/src/Application/Payments/LiqPay/TicketGroups/Commands/ProcessCallback/ProcessCallbackCommandHandler.cs b/src/Application/Payments/LiqPay/TicketGroups/Commands/ProcessCallback/ProcessCallbackCommandHandler.cs new file mode 100644 index 0000000..7a082c2 --- /dev/null +++ b/src/Application/Payments/LiqPay/TicketGroups/Commands/ProcessCallback/ProcessCallbackCommandHandler.cs @@ -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 +{ + 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(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(); + } +} diff --git a/src/Application/Payments/LiqPay/TicketGroups/Commands/ProcessCallback/ProcessCallbackCommandValidator.cs b/src/Application/Payments/LiqPay/TicketGroups/Commands/ProcessCallback/ProcessCallbackCommandValidator.cs new file mode 100644 index 0000000..70faa59 --- /dev/null +++ b/src/Application/Payments/LiqPay/TicketGroups/Commands/ProcessCallback/ProcessCallbackCommandValidator.cs @@ -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 +{ + public ProcessCallbackCommandValidator( + IStringLocalizer localizer, + SessionCultureService cultureService) + { + RuleFor(v => v.Data) + .NotEmpty() + .WithMessage(localizer["FluentValidation.NotEmpty"]); + + RuleFor(v => v.Signature) + .NotEmpty() + .WithMessage(localizer["FluentValidation.NotEmpty"]); + } +} diff --git a/src/Application/Payments/LiqPay/TicketGroups/Models/TicketGroupPaymentTicketModel.cs b/src/Application/Payments/LiqPay/TicketGroups/Models/TicketGroupPaymentTicketModel.cs new file mode 100644 index 0000000..4bc5a3b --- /dev/null +++ b/src/Application/Payments/LiqPay/TicketGroups/Models/TicketGroupPaymentTicketModel.cs @@ -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; } +} diff --git a/src/Application/Payments/LiqPay/TicketGroups/ViewModels/CallbackViewModel.cs b/src/Application/Payments/LiqPay/TicketGroups/ViewModels/CallbackViewModel.cs new file mode 100644 index 0000000..ca696c0 --- /dev/null +++ b/src/Application/Payments/LiqPay/TicketGroups/ViewModels/CallbackViewModel.cs @@ -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; } +} diff --git a/src/Application/Payments/LiqPay/TicketGroups/ViewModels/TicketGroupPaymentViewModel.cs b/src/Application/Payments/LiqPay/TicketGroups/ViewModels/TicketGroupPaymentViewModel.cs new file mode 100644 index 0000000..4d6d83b --- /dev/null +++ b/src/Application/Payments/LiqPay/TicketGroups/ViewModels/TicketGroupPaymentViewModel.cs @@ -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 Tickets { get; set; } + + + public string ResultPath { get; set; } +} diff --git a/src/Application/Payments/LiqPay/TicketGroups/ViewModels/TicketPaymentViewModel.cs b/src/Application/Payments/LiqPay/TicketGroups/ViewModels/TicketPaymentViewModel.cs new file mode 100644 index 0000000..f30684f --- /dev/null +++ b/src/Application/Payments/LiqPay/TicketGroups/ViewModels/TicketPaymentViewModel.cs @@ -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; } +} diff --git a/src/Application/Payments/PaymentLinkDto.cs b/src/Application/Payments/PaymentLinkDto.cs new file mode 100644 index 0000000..d47bde9 --- /dev/null +++ b/src/Application/Payments/PaymentLinkDto.cs @@ -0,0 +1,6 @@ +namespace cuqmbr.TravelGuide.Application.Payments; + +public sealed class PaymentLinkDto +{ + public string PaymentLink { get; set; } +} diff --git a/src/Application/Resources/Localization/en-US.json b/src/Application/Resources/Localization/en-US.json index be9ac72..dbec72c 100644 --- a/src/Application/Resources/Localization/en-US.json +++ b/src/Application/Resources/Localization/en-US.json @@ -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." } } diff --git a/src/Application/TicketGroups/Commands/AddTicketGroup/AddTicketGroupCommand.cs b/src/Application/TicketGroups/Commands/AddTicketGroup/AddTicketGroupCommand.cs index 554344b..a77bfee 100644 --- a/src/Application/TicketGroups/Commands/AddTicketGroup/AddTicketGroupCommand.cs +++ b/src/Application/TicketGroups/Commands/AddTicketGroup/AddTicketGroupCommand.cs @@ -18,7 +18,7 @@ public record AddTicketGroupCommand : IRequest public DateTimeOffset PurchaseTime { get; set; } - public bool Returned { get; set; } + public TicketStatus Status { get; set; } public ICollection Tickets { get; set; } diff --git a/src/Application/TicketGroups/Commands/AddTicketGroup/AddTicketGroupCommandHandler.cs b/src/Application/TicketGroups/Commands/AddTicketGroup/AddTicketGroupCommandHandler.cs index efe1455..25d4092 100644 --- a/src/Application/TicketGroups/Commands/AddTicketGroup/AddTicketGroupCommandHandler.cs +++ b/src/Application/TicketGroups/Commands/AddTicketGroup/AddTicketGroupCommandHandler.cs @@ -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 => diff --git a/src/Application/TicketGroups/Commands/AddTicketGroup/AddTicketGroupCommandValidator.cs b/src/Application/TicketGroups/Commands/AddTicketGroup/AddTicketGroupCommandValidator.cs index 8ead4b4..58a8029 100644 --- a/src/Application/TicketGroups/Commands/AddTicketGroup/AddTicketGroupCommandValidator.cs +++ b/src/Application/TicketGroups/Commands/AddTicketGroup/AddTicketGroupCommandValidator.cs @@ -67,6 +67,15 @@ public class AddTicketGroupCommandValidator : AbstractValidator 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"]); diff --git a/src/Application/TicketGroups/ViewModels/AddTicketGroupViewModel.cs b/src/Application/TicketGroups/ViewModels/AddTicketGroupViewModel.cs index dd292ff..9cac564 100644 --- a/src/Application/TicketGroups/ViewModels/AddTicketGroupViewModel.cs +++ b/src/Application/TicketGroups/ViewModels/AddTicketGroupViewModel.cs @@ -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 Tickets { get; set; } diff --git a/src/Application/packages.lock.json b/src/Application/packages.lock.json index ac19a92..14b60f0 100644 --- a/src/Application/packages.lock.json +++ b/src/Application/packages.lock.json @@ -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, )", diff --git a/src/Configuration/Configuration/Configuration.cs b/src/Configuration/Configuration/Configuration.cs index 0c6eaa9..5a8d825 100644 --- a/src/Configuration/Configuration/Configuration.cs +++ b/src/Configuration/Configuration/Configuration.cs @@ -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().Bind( + configuration.GetSection( + InfrastructureConfigurationOptions.SectionName)); + services.AddOptions().Bind( configuration.GetSection( IdentityConfigurationOptions.SectionName)); diff --git a/src/Configuration/Infrastructure/Configuration.cs b/src/Configuration/Infrastructure/Configuration.cs index 7e8793c..3df6bb0 100644 --- a/src/Configuration/Infrastructure/Configuration.cs +++ b/src/Configuration/Infrastructure/Configuration.cs @@ -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; } diff --git a/src/Configuration/packages.lock.json b/src/Configuration/packages.lock.json index 8fb4534..f253c37 100644 --- a/src/Configuration/packages.lock.json +++ b/src/Configuration/packages.lock.json @@ -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, )" } diff --git a/src/Domain/Entities/TicketGroup.cs b/src/Domain/Entities/TicketGroup.cs index b044ceb..c40a2ae 100644 --- a/src/Domain/Entities/TicketGroup.cs +++ b/src/Domain/Entities/TicketGroup.cs @@ -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; } diff --git a/src/Domain/Enums/TicketStatus.cs b/src/Domain/Enums/TicketStatus.cs new file mode 100644 index 0000000..b3c0b3a --- /dev/null +++ b/src/Domain/Enums/TicketStatus.cs @@ -0,0 +1,25 @@ +namespace cuqmbr.TravelGuide.Domain.Enums; + +public abstract class TicketStatus : Enumeration +{ + 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") { } + } +} diff --git a/src/HttpApi/Controllers/PaymentController.cs b/src/HttpApi/Controllers/PaymentController.cs new file mode 100644 index 0000000..b7c2e70 --- /dev/null +++ b/src/HttpApi/Controllers/PaymentController.cs @@ -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> 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); + } +} diff --git a/src/HttpApi/Controllers/TicketGroupsController.cs b/src/HttpApi/Controllers/TicketGroupsController.cs index 59b8cb6..adf8e3d 100644 --- a/src/HttpApi/Controllers/TicketGroupsController.cs +++ b/src/HttpApi/Controllers/TicketGroupsController.cs @@ -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() { diff --git a/src/HttpApi/appsettings.Development.json b/src/HttpApi/appsettings.Development.json index 2bdff62..8f2afcb 100644 --- a/src/HttpApi/appsettings.Development.json +++ b/src/HttpApi/appsettings.Development.json @@ -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": { diff --git a/src/HttpApi/appsettings.json b/src/HttpApi/appsettings.json index 2bdff62..8f2afcb 100644 --- a/src/HttpApi/appsettings.json +++ b/src/HttpApi/appsettings.json @@ -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": { diff --git a/src/HttpApi/packages.lock.json b/src/HttpApi/packages.lock.json index 58c6c3e..2d89b37 100644 --- a/src/HttpApi/packages.lock.json +++ b/src/HttpApi/packages.lock.json @@ -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, )" } diff --git a/src/Identity/packages.lock.json b/src/Identity/packages.lock.json index 22ffc53..5c6200a 100644 --- a/src/Identity/packages.lock.json +++ b/src/Identity/packages.lock.json @@ -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, )" } diff --git a/src/Infrastructure/ConfigurationOptions.cs b/src/Infrastructure/ConfigurationOptions.cs index 1d4b8a4..338297e 100644 --- a/src/Infrastructure/ConfigurationOptions.cs +++ b/src/Infrastructure/ConfigurationOptions.cs @@ -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; } } diff --git a/src/Infrastructure/Services/ExchangeApiCurrencyConverterService.cs b/src/Infrastructure/Services/ExchangeApiCurrencyConverterService.cs index ee4d9d0..e2bd323 100644 --- a/src/Infrastructure/Services/ExchangeApiCurrencyConverterService.cs +++ b/src/Infrastructure/Services/ExchangeApiCurrencyConverterService.cs @@ -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 : diff --git a/src/Infrastructure/Services/LiqPayPaymentService.cs b/src/Infrastructure/Services/LiqPayPaymentService.cs new file mode 100644 index 0000000..888cb97 --- /dev/null +++ b/src/Infrastructure/Services/LiqPayPaymentService.cs @@ -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, + IHttpClientFactory httpClientFactory) + { + _configuration = configurationOptions.Value.PaymentProcessing.LiqPay; + _callbackAddressBase = + configurationOptions.Value.PaymentProcessing.CallbackAddressBase; + _resultAddressBase = + configurationOptions.Value.PaymentProcessing.ResultAddressBase; + } + + public Task 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 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)); + } +} diff --git a/src/Infrastructure/packages.lock.json b/src/Infrastructure/packages.lock.json index f67a272..b07e4dd 100644 --- a/src/Infrastructure/packages.lock.json +++ b/src/Infrastructure/packages.lock.json @@ -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, )" } diff --git a/src/Persistence/InMemory/InMemoryDbContext.cs b/src/Persistence/InMemory/InMemoryDbContext.cs index 3dc85f8..41f3171 100644 --- a/src/Persistence/InMemory/InMemoryDbContext.cs +++ b/src/Persistence/InMemory/InMemoryDbContext.cs @@ -47,6 +47,11 @@ public class InMemoryDbContext : DbContext .HaveColumnType("varchar(32)") .HaveConversion(); + builder + .Properties() + .HaveColumnType("varchar(32)") + .HaveConversion(); + builder .Properties() .HaveConversion(); diff --git a/src/Persistence/PostgreSql/Configurations/TicketGroupConfiguration.cs b/src/Persistence/PostgreSql/Configurations/TicketGroupConfiguration.cs index 9bc2947..10e4a65 100644 --- a/src/Persistence/PostgreSql/Configurations/TicketGroupConfiguration.cs +++ b/src/Persistence/PostgreSql/Configurations/TicketGroupConfiguration.cs @@ -14,18 +14,36 @@ public class TicketGroupConfiguration : BaseConfiguration .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 .HasColumnType("timestamptz") .IsRequired(true); - builder - .Property(a => a.Returned) - .HasColumnName("returned") - .HasColumnType("boolean") - .IsRequired(true); - builder .Property(a => a.TravelTime) .HasColumnName("travel_time") diff --git a/src/Persistence/PostgreSql/Migrations/20250524184743_Add_status_to_Ticket_Group.Designer.cs b/src/Persistence/PostgreSql/Migrations/20250524184743_Add_status_to_Ticket_Group.Designer.cs new file mode 100644 index 0000000..6338c55 --- /dev/null +++ b/src/Persistence/PostgreSql/Migrations/20250524184743_Add_status_to_Ticket_Group.Designer.cs @@ -0,0 +1,1019 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using cuqmbr.TravelGuide.Persistence.PostgreSql; + +#nullable disable + +namespace Persistence.PostgreSql.Migrations +{ + [DbContext(typeof(PostgreSqlDbContext))] + [Migration("20250524184743_Add_status_to_Ticket_Group")] + partial class Add_status_to_Ticket_Group + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("application") + .HasAnnotation("ProductVersion", "9.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.HasSequence("addresses_id_sequence"); + + modelBuilder.HasSequence("cities_id_sequence"); + + modelBuilder.HasSequence("companies_id_sequence"); + + modelBuilder.HasSequence("countries_id_sequence"); + + modelBuilder.HasSequence("employee_documents_id_sequence"); + + modelBuilder.HasSequence("employees_id_sequence"); + + modelBuilder.HasSequence("regions_id_sequence"); + + modelBuilder.HasSequence("route_address_details_id_sequence"); + + modelBuilder.HasSequence("route_addresses_id_sequence"); + + modelBuilder.HasSequence("routes_id_sequence"); + + modelBuilder.HasSequence("ticket_groups_id_sequence"); + + modelBuilder.HasSequence("tickets_id_sequence"); + + modelBuilder.HasSequence("vehicle_enrollments_id_sequence"); + + modelBuilder.HasSequence("vehicles_id_sequence"); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Address", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasDefaultValueSql("nextval('application.addresses_id_sequence')"); + + NpgsqlPropertyBuilderExtensions.UseSequence(b.Property("Id"), "addresses_id_sequence"); + + b.Property("CityId") + .HasColumnType("bigint") + .HasColumnName("city_id"); + + b.Property("Guid") + .HasColumnType("uuid") + .HasColumnName("uuid"); + + b.Property("Latitude") + .HasColumnType("double precision"); + + b.Property("Longitude") + .HasColumnType("double precision"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(128)") + .HasColumnName("name"); + + b.Property("VehicleType") + .IsRequired() + .HasColumnType("varchar(16)") + .HasColumnName("vehicle_type"); + + b.HasKey("Id") + .HasName("pk_addresses"); + + b.HasAlternateKey("Guid") + .HasName("altk_addresses_uuid"); + + b.HasIndex("CityId") + .HasDatabaseName("ix_addresses_city_id"); + + b.ToTable("addresses", "application", t => + { + t.HasCheckConstraint("ck_addresses_vehicle_type", "vehicle_type IN ('bus', 'train', 'aircraft')"); + }); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.City", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasDefaultValueSql("nextval('application.cities_id_sequence')"); + + NpgsqlPropertyBuilderExtensions.UseSequence(b.Property("Id"), "cities_id_sequence"); + + b.Property("Guid") + .HasColumnType("uuid") + .HasColumnName("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(64)") + .HasColumnName("name"); + + b.Property("RegionId") + .HasColumnType("bigint") + .HasColumnName("region_id"); + + b.HasKey("Id") + .HasName("pk_cities"); + + b.HasAlternateKey("Guid") + .HasName("altk_cities_uuid"); + + b.HasIndex("RegionId") + .HasDatabaseName("ix_cities_region_id"); + + b.ToTable("cities", "application"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Company", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasDefaultValueSql("nextval('application.companies_id_sequence')"); + + NpgsqlPropertyBuilderExtensions.UseSequence(b.Property("Id"), "companies_id_sequence"); + + b.Property("ContactEmail") + .IsRequired() + .HasColumnType("varchar(256)") + .HasColumnName("contact_email"); + + b.Property("ContactPhoneNumber") + .IsRequired() + .HasColumnType("varchar(64)") + .HasColumnName("contact_phone_number"); + + b.Property("Guid") + .HasColumnType("uuid") + .HasColumnName("uuid"); + + b.Property("LegalAddress") + .IsRequired() + .HasColumnType("varchar(256)") + .HasColumnName("legal_address"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(64)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_companies"); + + b.HasAlternateKey("Guid") + .HasName("altk_companies_uuid"); + + b.ToTable("companies", "application"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Country", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasDefaultValueSql("nextval('application.countries_id_sequence')"); + + NpgsqlPropertyBuilderExtensions.UseSequence(b.Property("Id"), "countries_id_sequence"); + + b.Property("Guid") + .HasColumnType("uuid") + .HasColumnName("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(64)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_countries"); + + b.HasAlternateKey("Guid") + .HasName("altk_countries_uuid"); + + b.ToTable("countries", "application"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasDefaultValueSql("nextval('application.employees_id_sequence')"); + + NpgsqlPropertyBuilderExtensions.UseSequence(b.Property("Id"), "employees_id_sequence"); + + b.Property("BirthDate") + .HasColumnType("date") + .HasColumnName("birth_date"); + + b.Property("CompanyId") + .HasColumnType("bigint") + .HasColumnName("company_id"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("varchar(32)") + .HasColumnName("first_name"); + + b.Property("Guid") + .HasColumnType("uuid") + .HasColumnName("uuid"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("varchar(32)") + .HasColumnName("last_name"); + + b.Property("Patronymic") + .IsRequired() + .HasColumnType("varchar(32)") + .HasColumnName("patronymic"); + + b.Property("Sex") + .IsRequired() + .HasColumnType("varchar(32)") + .HasColumnName("sex"); + + b.HasKey("Id") + .HasName("pk_employees"); + + b.HasAlternateKey("Guid") + .HasName("altk_employees_uuid"); + + b.HasIndex("CompanyId") + .HasDatabaseName("ix_employees_company_id"); + + b.ToTable("employees", "application", t => + { + t.HasCheckConstraint("ck_employees_sex", "sex IN ('male', 'female')"); + }); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.EmployeeDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasDefaultValueSql("nextval('application.employee_documents_id_sequence')"); + + NpgsqlPropertyBuilderExtensions.UseSequence(b.Property("Id"), "employee_documents_id_sequence"); + + b.Property("DocumentType") + .IsRequired() + .HasColumnType("varchar(64)") + .HasColumnName("document_type"); + + b.Property("EmployeeId") + .HasColumnType("bigint") + .HasColumnName("employee_id"); + + b.Property("Guid") + .HasColumnType("uuid") + .HasColumnName("uuid"); + + b.Property("Information") + .IsRequired() + .HasColumnType("varchar(256)") + .HasColumnName("information"); + + b.HasKey("Id") + .HasName("pk_employee_documents"); + + b.HasAlternateKey("Guid") + .HasName("altk_employee_documents_uuid"); + + b.HasIndex("EmployeeId") + .HasDatabaseName("ix_employee_documents_employee_id"); + + b.ToTable("employee_documents", "application", t => + { + t.HasCheckConstraint("ck_employee_documents_document_type", "document_type IN ('passport')"); + }); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasDefaultValueSql("nextval('application.regions_id_sequence')"); + + NpgsqlPropertyBuilderExtensions.UseSequence(b.Property("Id"), "regions_id_sequence"); + + b.Property("CountryId") + .HasColumnType("bigint") + .HasColumnName("country_id"); + + b.Property("Guid") + .HasColumnType("uuid") + .HasColumnName("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(64)") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_regions"); + + b.HasAlternateKey("Guid") + .HasName("altk_regions_uuid"); + + b.HasIndex("CountryId") + .HasDatabaseName("ix_regions_country_id"); + + b.ToTable("regions", "application"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Route", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasDefaultValueSql("nextval('application.routes_id_sequence')"); + + NpgsqlPropertyBuilderExtensions.UseSequence(b.Property("Id"), "routes_id_sequence"); + + b.Property("Guid") + .HasColumnType("uuid") + .HasColumnName("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(64)") + .HasColumnName("name"); + + b.Property("VehicleType") + .IsRequired() + .HasColumnType("varchar(16)") + .HasColumnName("vehicle_type"); + + b.HasKey("Id") + .HasName("pk_routes"); + + b.HasAlternateKey("Guid") + .HasName("altk_routes_uuid"); + + b.ToTable("routes", "application", t => + { + t.HasCheckConstraint("ck_routes_vehicle_type", "vehicle_type IN ('bus', 'train', 'aircraft')"); + }); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.RouteAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasDefaultValueSql("nextval('application.route_addresses_id_sequence')"); + + NpgsqlPropertyBuilderExtensions.UseSequence(b.Property("Id"), "route_addresses_id_sequence"); + + b.Property("AddressId") + .HasColumnType("bigint") + .HasColumnName("address_id"); + + b.Property("Guid") + .HasColumnType("uuid") + .HasColumnName("uuid"); + + b.Property("Order") + .HasColumnType("smallint") + .HasColumnName("order"); + + b.Property("RouteId") + .HasColumnType("bigint") + .HasColumnName("route_id"); + + b.HasKey("Id") + .HasName("pk_route_addresses"); + + b.HasAlternateKey("Guid") + .HasName("altk_route_addresses_uuid"); + + b.HasAlternateKey("AddressId", "RouteId", "Order") + .HasName("altk_route_addresses_address_id_route_id_order"); + + b.HasIndex("AddressId") + .HasDatabaseName("ix_route_addresses_address_id"); + + b.HasIndex("RouteId") + .HasDatabaseName("ix_route_addresses_route_id"); + + b.ToTable("route_addresses", "application"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.RouteAddressDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasDefaultValueSql("nextval('application.route_address_details_id_sequence')"); + + NpgsqlPropertyBuilderExtensions.UseSequence(b.Property("Id"), "route_address_details_id_sequence"); + + b.Property("CostToNextAddress") + .HasColumnType("numeric(24,12)") + .HasColumnName("cost_to_next_address"); + + b.Property("CurrentAddressStopTime") + .HasColumnType("interval") + .HasColumnName("current_address_stop_time"); + + b.Property("Guid") + .HasColumnType("uuid") + .HasColumnName("uuid"); + + b.Property("RouteAddressId") + .HasColumnType("bigint") + .HasColumnName("route_address_id"); + + b.Property("TimeToNextAddress") + .HasColumnType("interval") + .HasColumnName("time_to_next_address"); + + b.Property("VehicleEnrollmentId") + .HasColumnType("bigint") + .HasColumnName("vehicle_enrollment_id"); + + b.HasKey("Id") + .HasName("pk_route_address_details"); + + b.HasAlternateKey("Guid") + .HasName("altk_route_address_details_uuid"); + + b.HasIndex("RouteAddressId") + .HasDatabaseName("ix_route_address_details_route_address_id"); + + b.HasIndex("VehicleEnrollmentId") + .HasDatabaseName("ix_route_address_details_vehicle_enrollment_id"); + + b.ToTable("route_address_details", "application"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Ticket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasDefaultValueSql("nextval('application.tickets_id_sequence')"); + + NpgsqlPropertyBuilderExtensions.UseSequence(b.Property("Id"), "tickets_id_sequence"); + + b.Property("ArrivalRouteAddressId") + .HasColumnType("bigint") + .HasColumnName("arrival_route_address_id"); + + b.Property("Cost") + .HasColumnType("numeric(24,12)") + .HasColumnName("cost"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("varchar(8)") + .HasColumnName("currency"); + + b.Property("DepartureRouteAddressId") + .HasColumnType("bigint") + .HasColumnName("departure_route_address_id"); + + b.Property("Guid") + .HasColumnType("uuid") + .HasColumnName("uuid"); + + b.Property("Order") + .HasColumnType("smallint") + .HasColumnName("order"); + + b.Property("TicketGroupId") + .HasColumnType("bigint") + .HasColumnName("ticket_group_id"); + + b.Property("VehicleEnrollmentId") + .HasColumnType("bigint") + .HasColumnName("vehicle_enrollment_id"); + + b.HasKey("Id") + .HasName("pk_tickets"); + + b.HasAlternateKey("Guid") + .HasName("altk_tickets_uuid"); + + b.HasIndex("ArrivalRouteAddressId"); + + b.HasIndex("DepartureRouteAddressId"); + + b.HasIndex("TicketGroupId") + .HasDatabaseName("ix_tickets_ticket_group_id"); + + b.HasIndex("VehicleEnrollmentId") + .HasDatabaseName("ix_tickets_vehicle_enrollment_id"); + + b.ToTable("tickets", "application", t => + { + t.HasCheckConstraint("ck_tickets_currency", "currency IN ('DEFAULT', 'USD', 'EUR', 'UAH')"); + }); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.TicketGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasDefaultValueSql("nextval('application.ticket_groups_id_sequence')"); + + NpgsqlPropertyBuilderExtensions.UseSequence(b.Property("Id"), "ticket_groups_id_sequence"); + + b.Property("Guid") + .HasColumnType("uuid") + .HasColumnName("uuid"); + + b.Property("PassangerBirthDate") + .HasColumnType("date") + .HasColumnName("passanger_birth_date"); + + b.Property("PassangerFirstName") + .IsRequired() + .HasColumnType("varchar(32)") + .HasColumnName("passanger_first_name"); + + b.Property("PassangerLastName") + .IsRequired() + .HasColumnType("varchar(32)") + .HasColumnName("passanger_last_name"); + + b.Property("PassangerPatronymic") + .IsRequired() + .HasColumnType("varchar(32)") + .HasColumnName("passanger_patronymic"); + + b.Property("PassangerSex") + .IsRequired() + .HasColumnType("varchar(32)") + .HasColumnName("passanger_sex"); + + b.Property("PurchaseTime") + .HasColumnType("timestamptz") + .HasColumnName("purchase_time"); + + b.Property("Status") + .IsRequired() + .HasColumnType("varchar(32)") + .HasColumnName("status"); + + b.Property("TravelTime") + .HasColumnType("interval") + .HasColumnName("travel_time"); + + b.HasKey("Id") + .HasName("pk_ticket_groups"); + + b.HasAlternateKey("Guid") + .HasName("altk_ticket_groups_uuid"); + + 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')"); + }); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Vehicle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasDefaultValueSql("nextval('application.vehicles_id_sequence')"); + + NpgsqlPropertyBuilderExtensions.UseSequence(b.Property("Id"), "vehicles_id_sequence"); + + b.Property("CompanyId") + .HasColumnType("bigint") + .HasColumnName("company_id"); + + b.Property("Guid") + .HasColumnType("uuid") + .HasColumnName("uuid"); + + b.Property("VehicleType") + .IsRequired() + .HasColumnType("varchar(16)") + .HasColumnName("vehicle_type"); + + b.HasKey("Id") + .HasName("pk_vehicles"); + + b.HasAlternateKey("Guid") + .HasName("altk_vehicles_uuid"); + + b.HasIndex("CompanyId") + .HasDatabaseName("ix_vehicles_company_id"); + + b.ToTable("vehicles", "application", t => + { + t.HasCheckConstraint("ck_vehicles_vehicle_type", "vehicle_type IN ('bus', 'train', 'aircraft')"); + }); + + b.HasDiscriminator("VehicleType"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.VehicleEnrollment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id") + .HasDefaultValueSql("nextval('application.vehicle_enrollments_id_sequence')"); + + NpgsqlPropertyBuilderExtensions.UseSequence(b.Property("Id"), "vehicle_enrollments_id_sequence"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("varchar(8)") + .HasColumnName("currency"); + + b.Property("DepartureTime") + .HasColumnType("timestamptz") + .HasColumnName("departure_time"); + + b.Property("Guid") + .HasColumnType("uuid") + .HasColumnName("uuid"); + + b.Property("RouteId") + .HasColumnType("bigint") + .HasColumnName("route_id"); + + b.Property("VehicleId") + .HasColumnType("bigint") + .HasColumnName("vehicle_id"); + + b.HasKey("Id") + .HasName("pk_vehicle_enrollments"); + + b.HasAlternateKey("Guid") + .HasName("altk_vehicle_enrollments_uuid"); + + b.HasIndex("RouteId") + .HasDatabaseName("ix_vehicle_enrollments_route_id"); + + b.HasIndex("VehicleId") + .HasDatabaseName("ix_vehicle_enrollments_vehicle_id"); + + b.ToTable("vehicle_enrollments", "application", t => + { + t.HasCheckConstraint("ck_vehicle_enrollments_currency", "currency IN ('DEFAULT', 'USD', 'EUR', 'UAH')"); + }); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Aircraft", b => + { + b.HasBaseType("cuqmbr.TravelGuide.Domain.Entities.Vehicle"); + + b.Property("Capacity") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("smallint") + .HasColumnName("capacity"); + + b.Property("Model") + .IsRequired() + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("varchar(64)") + .HasColumnName("model"); + + b.Property("Number") + .IsRequired() + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("varchar(32)") + .HasColumnName("number"); + + b.ToTable(t => + { + t.HasCheckConstraint("ck_vehicles_vehicle_type", "vehicle_type IN ('bus', 'train', 'aircraft')"); + }); + + b.HasDiscriminator().HasValue("aircraft"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Bus", b => + { + b.HasBaseType("cuqmbr.TravelGuide.Domain.Entities.Vehicle"); + + b.Property("Capacity") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("smallint") + .HasColumnName("capacity"); + + b.Property("Model") + .IsRequired() + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("varchar(64)") + .HasColumnName("model"); + + b.Property("Number") + .IsRequired() + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("varchar(32)") + .HasColumnName("number"); + + b.ToTable(t => + { + t.HasCheckConstraint("ck_vehicles_vehicle_type", "vehicle_type IN ('bus', 'train', 'aircraft')"); + }); + + b.HasDiscriminator().HasValue("bus"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Train", b => + { + b.HasBaseType("cuqmbr.TravelGuide.Domain.Entities.Vehicle"); + + b.Property("Capacity") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("smallint") + .HasColumnName("capacity"); + + b.Property("Model") + .IsRequired() + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("varchar(64)") + .HasColumnName("model"); + + b.Property("Number") + .IsRequired() + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("varchar(32)") + .HasColumnName("number"); + + b.ToTable(t => + { + t.HasCheckConstraint("ck_vehicles_vehicle_type", "vehicle_type IN ('bus', 'train', 'aircraft')"); + }); + + b.HasDiscriminator().HasValue("train"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Address", b => + { + b.HasOne("cuqmbr.TravelGuide.Domain.Entities.City", "City") + .WithMany("Addresses") + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_addresses_city_id"); + + b.Navigation("City"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.City", b => + { + b.HasOne("cuqmbr.TravelGuide.Domain.Entities.Region", "Region") + .WithMany("Cities") + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_cities_region_id"); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Employee", b => + { + b.HasOne("cuqmbr.TravelGuide.Domain.Entities.Company", "Company") + .WithMany("Employees") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_employees_company_id"); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.EmployeeDocument", b => + { + b.HasOne("cuqmbr.TravelGuide.Domain.Entities.Employee", "Employee") + .WithMany("Documents") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_employee_documents_employee_id"); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Region", b => + { + b.HasOne("cuqmbr.TravelGuide.Domain.Entities.Country", "Country") + .WithMany("Regions") + .HasForeignKey("CountryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_regions_country_id"); + + b.Navigation("Country"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.RouteAddress", b => + { + b.HasOne("cuqmbr.TravelGuide.Domain.Entities.Address", "Address") + .WithMany("AddressRoutes") + .HasForeignKey("AddressId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_route_addresses_address_id"); + + b.HasOne("cuqmbr.TravelGuide.Domain.Entities.Route", "Route") + .WithMany("RouteAddresses") + .HasForeignKey("RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_route_addresses_route_id"); + + b.Navigation("Address"); + + b.Navigation("Route"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.RouteAddressDetail", b => + { + b.HasOne("cuqmbr.TravelGuide.Domain.Entities.RouteAddress", "RouteAddress") + .WithMany("Details") + .HasForeignKey("RouteAddressId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_route_address_details_route_address_id"); + + b.HasOne("cuqmbr.TravelGuide.Domain.Entities.VehicleEnrollment", "VehicleEnrollment") + .WithMany("RouteAddressDetails") + .HasForeignKey("VehicleEnrollmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_route_address_details_vehicle_enrollment_id"); + + b.Navigation("RouteAddress"); + + b.Navigation("VehicleEnrollment"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Ticket", b => + { + b.HasOne("cuqmbr.TravelGuide.Domain.Entities.RouteAddress", "ArrivalRouteAddress") + .WithMany() + .HasForeignKey("ArrivalRouteAddressId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("cuqmbr.TravelGuide.Domain.Entities.RouteAddress", "DepartureRouteAddress") + .WithMany() + .HasForeignKey("DepartureRouteAddressId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("cuqmbr.TravelGuide.Domain.Entities.TicketGroup", "TicketGroup") + .WithMany("Tickets") + .HasForeignKey("TicketGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tickets_ticket_group_id"); + + b.HasOne("cuqmbr.TravelGuide.Domain.Entities.VehicleEnrollment", "VehicleEnrollment") + .WithMany("Tickets") + .HasForeignKey("VehicleEnrollmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tickets_vehicle_enrollment_id"); + + b.Navigation("ArrivalRouteAddress"); + + b.Navigation("DepartureRouteAddress"); + + b.Navigation("TicketGroup"); + + b.Navigation("VehicleEnrollment"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Vehicle", b => + { + b.HasOne("cuqmbr.TravelGuide.Domain.Entities.Company", "Company") + .WithMany("Vehicles") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_vehicles_company_id"); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.VehicleEnrollment", b => + { + b.HasOne("cuqmbr.TravelGuide.Domain.Entities.Route", "Route") + .WithMany("VehicleEnrollments") + .HasForeignKey("RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_vehicle_enrollments_route_id"); + + b.HasOne("cuqmbr.TravelGuide.Domain.Entities.Vehicle", "Vehicle") + .WithMany("Enrollments") + .HasForeignKey("VehicleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_vehicle_enrollments_vehicle_id"); + + b.Navigation("Route"); + + b.Navigation("Vehicle"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Address", b => + { + b.Navigation("AddressRoutes"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.City", b => + { + b.Navigation("Addresses"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Company", b => + { + b.Navigation("Employees"); + + b.Navigation("Vehicles"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Country", b => + { + b.Navigation("Regions"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Employee", b => + { + b.Navigation("Documents"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Region", b => + { + b.Navigation("Cities"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Route", b => + { + b.Navigation("RouteAddresses"); + + b.Navigation("VehicleEnrollments"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.RouteAddress", b => + { + b.Navigation("Details"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.TicketGroup", b => + { + b.Navigation("Tickets"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.Vehicle", b => + { + b.Navigation("Enrollments"); + }); + + modelBuilder.Entity("cuqmbr.TravelGuide.Domain.Entities.VehicleEnrollment", b => + { + b.Navigation("RouteAddressDetails"); + + b.Navigation("Tickets"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Persistence/PostgreSql/Migrations/20250524184743_Add_status_to_Ticket_Group.cs b/src/Persistence/PostgreSql/Migrations/20250524184743_Add_status_to_Ticket_Group.cs new file mode 100644 index 0000000..d3036f4 --- /dev/null +++ b/src/Persistence/PostgreSql/Migrations/20250524184743_Add_status_to_Ticket_Group.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Persistence.PostgreSql.Migrations +{ + /// + public partial class Add_status_to_Ticket_Group : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "returned", + schema: "application", + table: "ticket_groups"); + + migrationBuilder.AddColumn( + 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')"); + } + + /// + 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( + name: "returned", + schema: "application", + table: "ticket_groups", + type: "boolean", + nullable: false, + defaultValue: false); + } + } +} diff --git a/src/Persistence/PostgreSql/Migrations/PostgreSqlDbContextModelSnapshot.cs b/src/Persistence/PostgreSql/Migrations/PostgreSqlDbContextModelSnapshot.cs index fb4e67f..e4f59b1 100644 --- a/src/Persistence/PostgreSql/Migrations/PostgreSqlDbContextModelSnapshot.cs +++ b/src/Persistence/PostgreSql/Migrations/PostgreSqlDbContextModelSnapshot.cs @@ -577,9 +577,10 @@ namespace Persistence.PostgreSql.Migrations .HasColumnType("timestamptz") .HasColumnName("purchase_time"); - b.Property("Returned") - .HasColumnType("boolean") - .HasColumnName("returned"); + b.Property("Status") + .IsRequired() + .HasColumnType("varchar(32)") + .HasColumnName("status"); b.Property("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')"); }); }); diff --git a/src/Persistence/PostgreSql/PostgreSqlDbContext.cs b/src/Persistence/PostgreSql/PostgreSqlDbContext.cs index 11e5d63..0f5571c 100644 --- a/src/Persistence/PostgreSql/PostgreSqlDbContext.cs +++ b/src/Persistence/PostgreSql/PostgreSqlDbContext.cs @@ -54,6 +54,11 @@ public class PostgreSqlDbContext : DbContext .HaveColumnType("varchar(32)") .HaveConversion(); + builder + .Properties() + .HaveColumnType("varchar(32)") + .HaveConversion(); + builder .Properties() .HaveConversion(); diff --git a/src/Persistence/TypeConverters/TicketStatusConverter.cs b/src/Persistence/TypeConverters/TicketStatusConverter.cs new file mode 100644 index 0000000..5220fc8 --- /dev/null +++ b/src/Persistence/TypeConverters/TicketStatusConverter.cs @@ -0,0 +1,13 @@ +using cuqmbr.TravelGuide.Domain.Enums; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace cuqmbr.TravelGuide.Persistence.TypeConverters; + +public class TicketStatusConverter : ValueConverter +{ + public TicketStatusConverter() + : base( + v => v.Name, + v => TicketStatus.FromName(v)) + { } +} diff --git a/src/Persistence/packages.lock.json b/src/Persistence/packages.lock.json index 3cb01b7..4833b36 100644 --- a/src/Persistence/packages.lock.json +++ b/src/Persistence/packages.lock.json @@ -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, )" } diff --git a/tst/Application.IntegrationTests/packages.lock.json b/tst/Application.IntegrationTests/packages.lock.json index 4967b9d..117f2e7 100644 --- a/tst/Application.IntegrationTests/packages.lock.json +++ b/tst/Application.IntegrationTests/packages.lock.json @@ -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, )" }