add payment email notifications

This commit is contained in:
cuqmbr 2025-05-29 18:02:02 +03:00
parent 68a9e06eeb
commit 6a9504d6ff
Signed by: cuqmbr
GPG Key ID: 0AA446880C766199
16 changed files with 1723 additions and 41 deletions

View File

@ -17,6 +17,8 @@ public record GetPaymentLinkCommand : IRequest<PaymentLinkDto>
public DateOnly PassangerBirthDate { get; set; }
public string? PassangerEmail { get; set; }
public ICollection<TicketGroupPaymentTicketModel> Tickets { get; set; }

View File

@ -21,16 +21,27 @@ public class GetPaymentLinkCommandHandler :
private readonly IStringLocalizer _localizer;
private readonly EmailSenderService _emailSender;
private readonly SessionTimeZoneService _sessionTimeZoneService;
private readonly SessionCultureService _sessionCultureService;
public GetPaymentLinkCommandHandler(
UnitOfWork unitOfWork,
CurrencyConverterService currencyConverterService,
LiqPayPaymentService liqPayPaymentService,
IStringLocalizer localizer)
IStringLocalizer localizer,
EmailSenderService emailSender,
SessionTimeZoneService SessionTimeZoneService,
SessionCultureService sessionCultureService)
{
_unitOfWork = unitOfWork;
_currencyConverterService = currencyConverterService;
_liqPayPaymentService = liqPayPaymentService;
_localizer = localizer;
_emailSender = emailSender;
_sessionTimeZoneService = SessionTimeZoneService;
_sessionCultureService = sessionCultureService;
}
public async Task<PaymentLinkDto> Handle(
@ -336,7 +347,7 @@ public class GetPaymentLinkCommandHandler :
var costToDeparture = verad
.TakeWhile(rad => rad.Id != departureRouteAddressId)
.TakeWhile(rad => rad.RouteAddressId != departureRouteAddressId)
.Aggregate((decimal)0, (sum, next) =>
sum + next.CostToNextAddress);
@ -412,6 +423,7 @@ public class GetPaymentLinkCommandHandler :
PurchaseTime = DateTimeOffset.UtcNow,
Status = TicketStatus.Reserved,
TravelTime = travelTime,
PassangerEmail = request.PassangerEmail,
Tickets = request.Tickets.Select(
t =>
{
@ -428,12 +440,6 @@ public class GetPaymentLinkCommandHandler :
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,
@ -441,8 +447,8 @@ public class GetPaymentLinkCommandHandler :
ArrivalRouteAddressId = arrivalRouteAddress.Id,
ArrivalRouteAddress = arrivalRouteAddress,
Order = t.Order,
Cost = cost,
Currency = currency,
Cost = detail.cost,
Currency = detail.currency,
VehicleEnrollmentId = ve.Id
};
})
@ -456,7 +462,11 @@ public class GetPaymentLinkCommandHandler :
_unitOfWork.Dispose();
var amount = entity.Tickets.Sum(e => e.Cost);
var amount = entity.Tickets.Sum(e =>
_currencyConverterService
.ConvertAsync(
e.Cost, e.Currency, Currency.UAH,
cancellationToken).Result);
var guid = entity.Guid;
var validity = TimeSpan.FromMinutes(10);
var resultPath = request.ResultPath;
@ -465,9 +475,31 @@ public class GetPaymentLinkCommandHandler :
var paymentLink = _liqPayPaymentService
.GetPaymentLink(
amount, Currency.UAH, guid.ToString(), validity,
_localizer["PaymentProcessing.TicketPaymentDescription"],
_localizer["PaymentProcessing.Ticket.PaymentDescription"],
resultPath, callbackPath);
if (request.PassangerEmail != null)
{
var validUntil = DateTimeOffset.UtcNow
.Add(validity)
.ToOffset(_sessionTimeZoneService.TimeZone.BaseUtcOffset);
var subject =
_localizer["PaymentProcessing.Ticket" +
".Email.PaymentCreated.Subject"];
var body = String.Format(
_sessionCultureService.Culture,
_localizer["PaymentProcessing.Ticket" +
".Email.PaymentCreated.Body"],
Currency.UAH.Round(amount), Currency.UAH.Name,
validUntil, paymentLink);
await _emailSender.SendAsync(
new[] { request.PassangerEmail }, subject,
body, cancellationToken);
}
return new PaymentLinkDto() { PaymentLink = paymentLink };
}
}

View File

@ -61,6 +61,15 @@ public class GetPaymentLinkCommandValidator :
localizer["FluentValidation.GreaterThanOrEqualTo"],
DateOnly.FromDateTime(DateTime.UtcNow.AddYears(-100))));
When(tg => tg.PassangerEmail != null, () =>
{
RuleFor(v => v.PassangerEmail)
.NotEmpty()
.WithMessage(localizer["FluentValidation.NotEmpty"])
.IsEmail()
.WithMessage(localizer["FluentValidation.IsEmail"]);
});
RuleFor(tg => tg.Tickets)
.IsUnique(t => t.VehicleEnrollmentGuid)
.WithMessage(localizer["FluentValidation.IsUnique"]);

View File

@ -5,6 +5,8 @@ using cuqmbr.TravelGuide.Application.Common.Exceptions;
using System.Text;
using Newtonsoft.Json;
using cuqmbr.TravelGuide.Domain.Enums;
using Microsoft.Extensions.Localization;
using cuqmbr.TravelGuide.Domain.Entities;
namespace cuqmbr.TravelGuide.Application.Payments.LiqPay
.TicketGroups.Commands.ProcessCallback;
@ -13,20 +15,31 @@ public class ProcessCallbackCommandHandler :
IRequestHandler<ProcessCallbackCommand>
{
private readonly UnitOfWork _unitOfWork;
private readonly LiqPayPaymentService _liqPayPaymentService;
private readonly IStringLocalizer _localizer;
private readonly EmailSenderService _emailSender;
public ProcessCallbackCommandHandler(
UnitOfWork unitOfWork,
LiqPayPaymentService liqPayPaymentService)
LiqPayPaymentService liqPayPaymentService,
IStringLocalizer localizer,
EmailSenderService emailSender)
{
_unitOfWork = unitOfWork;
_liqPayPaymentService = liqPayPaymentService;
_localizer = localizer;
_emailSender = emailSender;
}
public async Task Handle(
ProcessCallbackCommand request,
CancellationToken cancellationToken)
{
// Validate signature.
var isSignatureValid = _liqPayPaymentService
.IsValidSignature(request.Data, request.Signature);
@ -35,6 +48,9 @@ public class ProcessCallbackCommandHandler :
throw new ForbiddenException();
}
// Parse request data.
var dataBytes = Convert.FromBase64String(request.Data);
var dataJson = Encoding.UTF8.GetString(dataBytes);
@ -42,9 +58,11 @@ public class ProcessCallbackCommandHandler :
string status = data.status;
var ticketGroupGuid = Guid.Parse((string)data.order_id);
var ticketGroup = await _unitOfWork.TicketGroupRepository
.GetOneAsync(e => e.Guid == ticketGroupGuid, cancellationToken);
.GetOneAsync(e => e.Guid == ticketGroupGuid,
e => e.Tickets, cancellationToken);
if (ticketGroup == null ||
ticketGroup.Status == TicketStatus.Purchased)
@ -52,6 +70,9 @@ public class ProcessCallbackCommandHandler :
throw new ForbiddenException();
}
// Process callback status
if (status.Equals("error") || status.Equals("failure"))
{
await _unitOfWork.TicketGroupRepository
@ -59,12 +80,228 @@ public class ProcessCallbackCommandHandler :
}
else if (status.Equals("success"))
{
// Update ticket status
ticketGroup.Status = TicketStatus.Purchased;
await _unitOfWork.TicketGroupRepository
.UpdateOneAsync(ticketGroup, cancellationToken);
// Hydrate ticket group
var vehicleEnrollmentIds =
ticketGroup.Tickets.Select(t => t.VehicleEnrollmentId);
var vehicleEnrollments = (await _unitOfWork.VehicleEnrollmentRepository
.GetPageAsync(
ve => vehicleEnrollmentIds.Contains(ve.Id),
ve => ve.Route.RouteAddresses,
1, vehicleEnrollmentIds.Count(), cancellationToken))
.Items;
var routeAddressIds = vehicleEnrollments
.SelectMany(ve => ve.Route.RouteAddresses)
.Select(ra => ra.Id);
var routeAddressDetails = (await _unitOfWork.RouteAddressDetailRepository
.GetPageAsync(
rad => routeAddressIds.Contains(rad.RouteAddressId),
1, routeAddressIds.Count(), cancellationToken))
.Items;
var addressIds = vehicleEnrollments
.SelectMany(ve => ve.Route.RouteAddresses)
.Select(ra => ra.AddressId);
var addresses = (await _unitOfWork.AddressRepository
.GetPageAsync(
a => addressIds.Contains(a.Id),
a => a.City.Region.Country,
1, addressIds.Count(), cancellationToken))
.Items;
var vehicleIds = vehicleEnrollments
.Select(ve => ve.VehicleId);
var vehicles = (await _unitOfWork.VehicleRepository
.GetPageAsync(
v => vehicleIds.Contains(v.Id),
v => v.Company,
1, vehicleIds.Count(), cancellationToken))
.Items;
foreach (var ve in vehicleEnrollments)
{
ve.Vehicle = vehicles.Single(v => v.Id == ve.VehicleId);
foreach (var ra in ve.Route.RouteAddresses)
{
ra.Address = addresses.Single(a => a.Id == ra.AddressId);
ra.Details = routeAddressDetails
.Where(rad => rad.RouteAddressId == ra.Id)
.ToArray();
}
}
foreach (var t in ticketGroup.Tickets)
{
t.VehicleEnrollment = vehicleEnrollments
.Single(ve => ve.Id == t.VehicleEnrollmentId);
}
// Send email
if (ticketGroup.PassangerEmail != null)
{
var subject =
_localizer["PaymentProcessing.Ticket" +
".Email.PaymentCompleted.Subject"];
var ticketDetails = GetTicketDetails(ticketGroup);
var body = String.Format(
_localizer["PaymentProcessing.Ticket" +
".Email.PaymentCompleted.Body"],
ticketDetails);
await _emailSender.SendAsync(
new[] { ticketGroup.PassangerEmail }, subject,
body, cancellationToken);
}
}
await _unitOfWork.SaveAsync(cancellationToken);
_unitOfWork.Dispose();
}
private string GetTicketDetails(TicketGroup ticketGroup)
{
var sb = new StringBuilder();
sb.AppendLine("General:");
sb.AppendLine();
sb.AppendLine($"Ticket uuid: {ticketGroup.Guid}");
sb.AppendLine($"Purchase Time: {ticketGroup.PurchaseTime}");
sb.AppendLine();
var departureRouteAddressId =
ticketGroup.Tickets.First().DepartureRouteAddressId;
var arrivalRouteAddressId =
ticketGroup.Tickets.Last().ArrivalRouteAddressId;
var departureTime =
ticketGroup.Tickets.First()
.VehicleEnrollment.GetDepartureTime(departureRouteAddressId);
var arrivalTime =
ticketGroup.Tickets.Last()
.VehicleEnrollment.GetArrivalTime(arrivalRouteAddressId);
var departureAddress =
ticketGroup.Tickets.First()
.VehicleEnrollment.Route.RouteAddresses
.Single(ra => ra.Id == departureRouteAddressId)
.Address;
var arrivalAddress =
ticketGroup.Tickets.Last()
.VehicleEnrollment.Route.RouteAddresses
.Single(ra => ra.Id == arrivalRouteAddressId)
.Address;
var departureAddressName =
$"{departureAddress.City.Region.Country.Name}, " +
$"{departureAddress.City.Region.Name}, " +
$"{departureAddress.City.Name}, " +
$"{departureAddress.Name}";
var arrivalAddressName =
$"{arrivalAddress.City.Region.Country.Name}, " +
$"{arrivalAddress.City.Region.Name}, " +
$"{arrivalAddress.City.Name}, " +
$"{arrivalAddress.Name}";
sb.AppendLine($"Departure: {departureAddressName} at {departureTime}.");
sb.AppendLine($"Arrival: {arrivalAddressName} at {arrivalTime}.");
sb.AppendLine();
sb.AppendLine();
sb.AppendLine($"Passanger details:");
sb.AppendLine();
sb.AppendLine($"First Name: {ticketGroup.PassangerFirstName}.");
sb.AppendLine($"Last Name: {ticketGroup.PassangerLastName}.");
sb.AppendLine($"Patronymic: {ticketGroup.PassangerPatronymic}.");
sb.AppendLine($"Sex: {ticketGroup.PassangerSex}.");
sb.AppendLine($"Birth Date: {ticketGroup.PassangerBirthDate}.");
sb.AppendLine($"Email: {ticketGroup.PassangerEmail}.");
sb.AppendLine();
sb.AppendLine();
sb.AppendLine("Vehicle enrollments' details:");
sb.AppendLine();
foreach (var t in ticketGroup.Tickets)
{
departureRouteAddressId = t.DepartureRouteAddressId;
arrivalRouteAddressId = t.ArrivalRouteAddressId;
departureTime =
t.VehicleEnrollment.GetDepartureTime(departureRouteAddressId);
arrivalTime =
t.VehicleEnrollment.GetArrivalTime(arrivalRouteAddressId);
departureAddress =
t.VehicleEnrollment.Route.RouteAddresses
.Single(ra => ra.Id == departureRouteAddressId)
.Address;
arrivalAddress =
t.VehicleEnrollment.Route.RouteAddresses
.Single(ra => ra.Id == arrivalRouteAddressId)
.Address;
departureAddressName =
$"{departureAddress.City.Region.Country.Name}, " +
$"{departureAddress.City.Region.Name}, " +
$"{departureAddress.City.Name}, " +
$"{departureAddress.Name}";
arrivalAddressName =
$"{arrivalAddress.City.Region.Country.Name}, " +
$"{arrivalAddress.City.Region.Name}, " +
$"{arrivalAddress.City.Name}, " +
$"{arrivalAddress.Name}";
var vehicle = t.VehicleEnrollment.Vehicle;
var company = vehicle.Company;
sb.AppendLine($"Departure: {departureAddressName} at {departureTime}.");
sb.AppendLine($"Arrival: {arrivalAddressName} at {arrivalTime}.");
if (vehicle is Bus)
{
sb.AppendLine($"Vehicle: Bus, {((Bus)vehicle).Model}, " +
$"{((Bus)vehicle).Number}.");
}
else if (vehicle is Aircraft)
{
sb.AppendLine($"Vehicle: Aircraft, {((Aircraft)vehicle).Model}, " +
$"{((Aircraft)vehicle).Number}.");
}
else if (vehicle is Train)
{
sb.AppendLine($"Vehicle: Train, {((Train)vehicle).Model}, " +
$"{((Train)vehicle).Number}.");
}
else
{
throw new NotImplementedException();
}
sb.AppendLine($"Company: {company.Name}, ({company.ContactEmail}, " +
$"{company.ContactPhoneNumber}).");
var cost = t.Currency.Round(
t.VehicleEnrollment
.GetCost(departureRouteAddressId,arrivalRouteAddressId));
sb.AppendLine($"Cost: {cost} {t.Currency.Name}");
sb.AppendLine();
}
return sb.ToString();
}
}

View File

@ -13,6 +13,8 @@ public sealed class TicketGroupPaymentViewModel
public DateOnly PassangerBirthDate { get; set; }
public string? PassangerEmail { get; set; }
public ICollection<TicketPaymentViewModel> Tickets { get; set; }

View File

@ -62,6 +62,18 @@
}
},
"PaymentProcessing": {
"TicketPaymentDescription": "Ticket purchase."
"Ticket": {
"PaymentDescription": "Ticket purchase.",
"Email": {
"PaymentCreated": {
"Subject": "Ticket purchase payment link.",
"Body": "You have reserved a ticket. Payment amount is {0} {1} Payment link is valid until {2}.\n\nLink: {3}"
},
"PaymentCompleted": {
"Subject": "Ticket purchase complete.",
"Body": "Payment is succeeded.\n\n\nTicket details:\n\n{0}"
}
}
}
}
}

View File

@ -357,16 +357,6 @@ public class SearchAllQueryHandler :
tag = path.Select(e => e.Tag).Last();
lastRouteAddressGuid = vehicleEnrollments
.Single(e => e.Id == tag.VehicleEnrollmentId)
.RouteAddressDetails
.Select(e => e.RouteAddress)
.OrderBy(e => e.Order)
.SkipWhile(e => e.Order != tag.RouteAddress.Order)
.Take(2)
.ElementAt(1)
.Guid;
costToNextAddress = await _currencyConverterService
.ConvertAsync(tag.CostToNextAddress,
tag.VehicleEnrollment.Currency,
@ -388,7 +378,7 @@ public class SearchAllQueryHandler :
CostToNextAddress = 0,
CurrentAddressStopTime = tag.CurrentAddressStopTime,
Order = addressOrder,
RouteAddressUuid = lastRouteAddressGuid
RouteAddressUuid = tag.RouteAddress.Guid
});

View File

@ -25,8 +25,9 @@ public static class Configuration
.AddCommandLine(args)
.Build();
services.AddOptions<PersistenceConfigurationOptions>()
.Bind(configuration);
services.AddOptions<PersistenceConfigurationOptions>().Bind(
configuration.GetSection(
PersistenceConfigurationOptions.SectionName));
services.AddOptions<ApplicationConfigurationOptions>()
.Bind(configuration);

View File

@ -14,6 +14,8 @@ public sealed class TicketGroup : EntityBase
public DateOnly PassangerBirthDate { get; set; }
public string? PassangerEmail { get; set; }
public DateTimeOffset PurchaseTime { get; set; }
public TicketStatus Status { get; set; }

View File

@ -121,7 +121,7 @@ public class VehicleEnrollment : EntityBase
.OrderBy(e => e.RouteAddress.Order);
var departureRouteAddressDetail = orderedRouteAddressDetails
.Single(e => e.Id == DepartureRouteAddressId);
.Single(e => e.RouteAddressId == DepartureRouteAddressId);
var timeInStops = TimeSpan.Zero;
foreach (var routeAddressDetail in orderedRouteAddressDetails)
@ -159,8 +159,8 @@ public class VehicleEnrollment : EntityBase
return
RouteAddressDetails
.OrderBy(e => e.RouteAddress.Order)
.SkipWhile(e => e.Id != DepartureRouteAddressId)
.TakeWhile(e => e.Id != ArrivalRouteAddressId)
.SkipWhile(e => e.RouteAddressId != DepartureRouteAddressId)
.TakeWhile(e => e.RouteAddressId != ArrivalRouteAddressId)
.Count() - 1;
}
@ -180,8 +180,8 @@ public class VehicleEnrollment : EntityBase
return
RouteAddressDetails
.OrderBy(e => e.RouteAddress.Order)
.SkipWhile(e => e.Id != DepartureRouteAddressId)
.TakeWhile(e => e.Id != ArrivalRouteAddressId)
.SkipWhile(e => e.RouteAddressId != DepartureRouteAddressId)
.TakeWhile(e => e.RouteAddressId != ArrivalRouteAddressId)
.Aggregate(TimeSpan.Zero,
(sum, next) => sum += next.TimeToNextAddress);
}
@ -202,8 +202,8 @@ public class VehicleEnrollment : EntityBase
return
RouteAddressDetails
.OrderBy(e => e.RouteAddress.Order)
.SkipWhile(e => e.Id != DepartureRouteAddressId)
.TakeWhile(e => e.Id != ArrivalRouteAddressId)
.SkipWhile(e => e.RouteAddressId != DepartureRouteAddressId)
.TakeWhile(e => e.RouteAddressId != ArrivalRouteAddressId)
.Aggregate((decimal)0,
(sum, next) => sum += next.CostToNextAddress);
}

View File

@ -14,24 +14,39 @@ public abstract class Currency : Enumeration<Currency>
protected Currency(int value, string name) : base(value, name) { }
protected virtual byte DecimalDigits { get; } = byte.MaxValue;
public decimal Round(decimal amount)
{
return Math.Round(amount, DecimalDigits);
}
// When no currency is specified
private sealed class DefaultCurrency : Currency
{
public DefaultCurrency() : base(Int32.MaxValue, "DEFAULT") { }
protected override byte DecimalDigits => 2;
}
private sealed class USDCurrency : Currency
{
public USDCurrency() : base(840, "USD") { }
protected override byte DecimalDigits => 2;
}
private sealed class EURCurrency : Currency
{
public EURCurrency() : base(978, "EUR") { }
protected override byte DecimalDigits => 2;
}
private sealed class UAHCurrency : Currency
{
public UAHCurrency() : base(980, "UAH") { }
protected override byte DecimalDigits => 2;
}
}

View File

@ -48,6 +48,7 @@ public class PaymentController : ControllerBase
PassangerPatronymic = viewModel.PassangerPatronymic,
PassangerSex = Sex.FromName(viewModel.PassangerSex),
PassangerBirthDate = viewModel.PassangerBirthDate,
PassangerEmail = viewModel.PassangerEmail,
Tickets = viewModel.Tickets.Select(e =>
new TicketGroupPaymentTicketModel()
{

View File

@ -49,37 +49,43 @@ public class TicketGroupConfiguration : BaseConfiguration<TicketGroup>
builder
.Property(a => a.PassangerFirstName)
.Property(tg => tg.PassangerFirstName)
.HasColumnName("passanger_first_name")
.HasColumnType("varchar(32)")
.IsRequired(true);
builder
.Property(a => a.PassangerLastName)
.Property(tg => tg.PassangerLastName)
.HasColumnName("passanger_last_name")
.HasColumnType("varchar(32)")
.IsRequired(true);
builder
.Property(a => a.PassangerPatronymic)
.Property(tg => tg.PassangerPatronymic)
.HasColumnName("passanger_patronymic")
.HasColumnType("varchar(32)")
.IsRequired(true);
builder
.Property(a => a.PassangerBirthDate)
.Property(tg => tg.PassangerBirthDate)
.HasColumnName("passanger_birth_date")
.HasColumnType("date")
.IsRequired(true);
builder
.Property(a => a.PurchaseTime)
.Property(tg => tg.PassangerEmail)
.HasColumnName("passanger_email")
.HasColumnType("varchar(256)")
.IsRequired(false);
builder
.Property(tg => tg.PurchaseTime)
.HasColumnName("purchase_time")
.HasColumnType("timestamptz")
.IsRequired(true);
builder
.Property(a => a.TravelTime)
.Property(tg => tg.TravelTime)
.HasColumnName("travel_time")
.HasColumnType("interval")
.IsRequired(true);

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Persistence.PostgreSql.Migrations
{
/// <inheritdoc />
public partial class Add_email_to_Ticket_Group : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "passanger_email",
schema: "application",
table: "ticket_groups",
type: "varchar(256)",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "passanger_email",
schema: "application",
table: "ticket_groups");
}
}
}

View File

@ -737,6 +737,10 @@ namespace Persistence.PostgreSql.Migrations
.HasColumnType("date")
.HasColumnName("passanger_birth_date");
b.Property<string>("PassangerEmail")
.HasColumnType("varchar(256)")
.HasColumnName("passanger_email");
b.Property<string>("PassangerFirstName")
.IsRequired()
.HasColumnType("varchar(32)")