From 2a789569357391340962eaa9da8290b5579a1de1 Mon Sep 17 00:00:00 2001 From: Nima Haji Date: Sun, 7 Jun 2026 02:10:48 +0330 Subject: [PATCH 1/4] Callback payment implemented return bool --- .../Appointments/DTOs/CreateAppointment.cs | 2 + .../Services/AppointmentService.cs | 2 +- .../Features/Payment/DTOs/CreatePaymentDto.cs | 5 +- .../Features/Payment/DTOs/SepCallBackDto.cs | 26 ++ .../Interfaces/PaymentRepositoryContract.cs | 11 + .../Interfaces/PaymentServiceContract.cs | 4 + .../Payment/Services/PaymentService.cs | 39 +- .../{CreateService.cs => CreateServiceDto.cs} | 3 +- .../Service/Services/IServiceAppService.cs | 2 +- .../Service/Services/ServiceAppService.cs | 4 +- .../Service/CreateServiceValidator.cs | 2 +- Domain/Domain.csproj | 4 + Domain/Entities/Appointment.cs | 5 +- Domain/Entities/Payment.cs | 67 +++ Domain/Entities/Service.cs | 3 +- Infrastructure/InfrastructureServices.cs | 2 + .../20260603120331_Payment.Designer.cs | 391 +++++++++++++++++ .../Migrations/20260603120331_Payment.cs | 66 +++ ...20260606121723_TraceNoNullable.Designer.cs | 390 +++++++++++++++++ .../20260606121723_TraceNoNullable.cs | 36 ++ ...60606222719_FixingPaymentTable.Designer.cs | 398 ++++++++++++++++++ .../20260606222719_FixingPaymentTable.cs | 78 ++++ Infrastructure/Persistence/AppDbContext.cs | 1 + .../Configurations/PaymentConfiguration.cs | 66 +++ .../Migrations/AppDbContextModelSnapshot.cs | 77 ++++ .../Repositories/PaymentRepository.cs | 40 ++ api/Controllers/PaymentController.cs | 53 ++- api/Controllers/ServiceController.cs | 4 +- 28 files changed, 1752 insertions(+), 29 deletions(-) create mode 100644 Application/Features/Payment/DTOs/SepCallBackDto.cs create mode 100644 Application/Features/Payment/Interfaces/PaymentRepositoryContract.cs rename Application/Features/Service/DTOs/{CreateService.cs => CreateServiceDto.cs} (65%) create mode 100644 Domain/Entities/Payment.cs create mode 100644 Infrastructure/Migrations/20260603120331_Payment.Designer.cs create mode 100644 Infrastructure/Migrations/20260603120331_Payment.cs create mode 100644 Infrastructure/Migrations/20260606121723_TraceNoNullable.Designer.cs create mode 100644 Infrastructure/Migrations/20260606121723_TraceNoNullable.cs create mode 100644 Infrastructure/Migrations/20260606222719_FixingPaymentTable.Designer.cs create mode 100644 Infrastructure/Migrations/20260606222719_FixingPaymentTable.cs create mode 100644 Infrastructure/Persistence/Configurations/PaymentConfiguration.cs create mode 100644 Infrastructure/Persistence/Repositories/PaymentRepository.cs diff --git a/Application/Features/Appointments/DTOs/CreateAppointment.cs b/Application/Features/Appointments/DTOs/CreateAppointment.cs index 5d3c7bf..b11c44b 100644 --- a/Application/Features/Appointments/DTOs/CreateAppointment.cs +++ b/Application/Features/Appointments/DTOs/CreateAppointment.cs @@ -12,4 +12,6 @@ public class CreateAppointment public DateTime EndTime { get; set; } [Required] public string AppoinmentTitle { get; set; } + [Required] + public Guid TenantId { get; set; } } \ No newline at end of file diff --git a/Application/Features/Appointments/Services/AppointmentService.cs b/Application/Features/Appointments/Services/AppointmentService.cs index c81356c..6e7bd74 100644 --- a/Application/Features/Appointments/Services/AppointmentService.cs +++ b/Application/Features/Appointments/Services/AppointmentService.cs @@ -40,7 +40,7 @@ public async Task CreateAppointmentAsync(CreateAppointment dto) throw new UnauthorizedAccessException("Not Authenticated"); var userId = _userContext.UserId; - var appointment = new Appointment(userId, dto.StartTime, dto.EndTime, dto.AppoinmentTitle); + var appointment = new Appointment(userId, dto.StartTime, dto.EndTime, dto.AppoinmentTitle,dto.TenantId); if (!await _userRepository.IsUserExistsByIdAsync(userId)) throw new NotFoundException($"User with {userId} does not exist"); diff --git a/Application/Features/Payment/DTOs/CreatePaymentDto.cs b/Application/Features/Payment/DTOs/CreatePaymentDto.cs index 2e6111d..d495777 100644 --- a/Application/Features/Payment/DTOs/CreatePaymentDto.cs +++ b/Application/Features/Payment/DTOs/CreatePaymentDto.cs @@ -1,7 +1,10 @@ +using Domain.Entities; + namespace Application.Features.Payment.DTOs; public class CreatePaymentDto { + public Guid TenantId { get; set; } + public Guid AppointmentId { get; set; } public long Amount { get; set; } - public string PhoneNumber { get; set; } } \ No newline at end of file diff --git a/Application/Features/Payment/DTOs/SepCallBackDto.cs b/Application/Features/Payment/DTOs/SepCallBackDto.cs new file mode 100644 index 0000000..847090e --- /dev/null +++ b/Application/Features/Payment/DTOs/SepCallBackDto.cs @@ -0,0 +1,26 @@ +public class SandBoxCallBackDto +{ + public string? MID { get; set; } + + public int? Status { get; set; } + + public string? State { get; set; } + + public string? RRN { get; set; } + + public string? RefNum { get; set; } + + public string? ResNum { get; set; } + + public string? TraceNo { get; set; } + + public long Amount { get; set; } + + public string? Wage { get; set; } + + public string? CID { get; set; } + + public string? SecurePan { get; set; } + + public string? Token { get; set; } +} \ No newline at end of file diff --git a/Application/Features/Payment/Interfaces/PaymentRepositoryContract.cs b/Application/Features/Payment/Interfaces/PaymentRepositoryContract.cs new file mode 100644 index 0000000..e017343 --- /dev/null +++ b/Application/Features/Payment/Interfaces/PaymentRepositoryContract.cs @@ -0,0 +1,11 @@ +using Application.Features.Payment.DTOs; + +namespace Application.Features.Payment.Interfaces; + +public interface PaymentRepositoryContract +{ + Task CreatePaymentAsync(Domain.Entities.Payment payment); + Task SaveAsync(); + Task GetPaymentByResNumAsync(string resNum); + Task IsExistByRefNum(string refNum); +} \ No newline at end of file diff --git a/Application/Features/Payment/Interfaces/PaymentServiceContract.cs b/Application/Features/Payment/Interfaces/PaymentServiceContract.cs index 0d17493..9293251 100644 --- a/Application/Features/Payment/Interfaces/PaymentServiceContract.cs +++ b/Application/Features/Payment/Interfaces/PaymentServiceContract.cs @@ -1,6 +1,10 @@ +using Application.Features.Payment.DTOs; + namespace Application.Features.Payment.Interfaces; public interface PaymentServiceContract { Task GenerateResNum(); + Task CreatePaymentAsync(CreatePaymentDto dto); + Task ProccessCallBack(SandBoxCallBackDto dto); } \ No newline at end of file diff --git a/Application/Features/Payment/Services/PaymentService.cs b/Application/Features/Payment/Services/PaymentService.cs index 74d3034..a67ed38 100644 --- a/Application/Features/Payment/Services/PaymentService.cs +++ b/Application/Features/Payment/Services/PaymentService.cs @@ -1,12 +1,47 @@ using System.Security.Cryptography; +using Application.Features.Payment.DTOs; using Application.Features.Payment.Interfaces; namespace Application.Features.Payment.Services; -public class PaymentService:PaymentServiceContract +public class PaymentService : PaymentServiceContract { + private readonly PaymentRepositoryContract _paymentRepository; + + public PaymentService(PaymentRepositoryContract paymentRepository) + { + _paymentRepository = paymentRepository; + } + public async Task GenerateResNum() { - return $"{Guid.NewGuid():N}"; + string timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss"); + string shortGuid = Guid.NewGuid().ToString("N").Substring(0, 8); + return $"{timestamp}{shortGuid}"; + } + + public async Task CreatePaymentAsync(CreatePaymentDto dto) + { + var resNum = await GenerateResNum(); + var payment = new Domain.Entities.Payment(dto.TenantId, dto.AppointmentId, dto.Amount, resNum); + await _paymentRepository.CreatePaymentAsync(payment); + await _paymentRepository.SaveAsync(); + return resNum; + } + + public async Task ProccessCallBack(SandBoxCallBackDto dto) + { + if (dto.State != "OK") + throw new Exception("پرداخت ناموفق"); + if (dto.ResNum == null) + throw new Exception("شماره رزرو نامعتبر است ."); + var payment = await _paymentRepository.GetPaymentByResNumAsync(dto.ResNum); + if (payment == null) + throw new Exception("تراکنش نامعتبر"); + payment.Edit(dto.Status, dto.State, dto.RRN, dto.RefNum, dto.ResNum, dto.TraceNo, dto.Amount, dto.Wage); + var isValid = dto.State != "OK" || dto.Status != 2; + await _paymentRepository.SaveAsync(); + + return isValid; } } \ No newline at end of file diff --git a/Application/Features/Service/DTOs/CreateService.cs b/Application/Features/Service/DTOs/CreateServiceDto.cs similarity index 65% rename from Application/Features/Service/DTOs/CreateService.cs rename to Application/Features/Service/DTOs/CreateServiceDto.cs index f5c2c72..498fbd7 100644 --- a/Application/Features/Service/DTOs/CreateService.cs +++ b/Application/Features/Service/DTOs/CreateServiceDto.cs @@ -1,7 +1,8 @@ namespace Application.Features.Service.DTOs; -public class CreateService +public class CreateServiceDto { public string Title { get; set; } public int DurationMinutes { get; set; } + public Guid TenantId { get; set; } } \ No newline at end of file diff --git a/Application/Features/Service/Services/IServiceAppService.cs b/Application/Features/Service/Services/IServiceAppService.cs index 915c0bc..87a1ea5 100644 --- a/Application/Features/Service/Services/IServiceAppService.cs +++ b/Application/Features/Service/Services/IServiceAppService.cs @@ -4,7 +4,7 @@ namespace Application.Features.Service.Services; public interface IServiceAppService { - Task CreateServiceAsync(CreateService createService); + Task CreateServiceAsync(CreateServiceDto createServiceDto); Task EditServiceAsync(Guid serviceId,EditService editService); Task DeleteServiceAsync(Guid serviceId); Task> ViewAllServicesAsync(); diff --git a/Application/Features/Service/Services/ServiceAppService.cs b/Application/Features/Service/Services/ServiceAppService.cs index 5f9094e..6e850c3 100644 --- a/Application/Features/Service/Services/ServiceAppService.cs +++ b/Application/Features/Service/Services/ServiceAppService.cs @@ -21,10 +21,10 @@ private void EnsureSuperAdmin() if (_userContext.Role != UserRole.SuperAdmin) throw new ForbiddenAccessException("Only Admin Access"); } - public async Task CreateServiceAsync(CreateService createService) + public async Task CreateServiceAsync(CreateServiceDto createServiceDto) { EnsureSuperAdmin(); - var service=new Domain.Entities.Service(createService.Title,createService.DurationMinutes); + var service=new Domain.Entities.Service(createServiceDto.Title,createServiceDto.DurationMinutes,createServiceDto.TenantId); await _repository.CreatServiceAsync(service); return $"{service.Title} created"; } diff --git a/Application/Validators/Service/CreateServiceValidator.cs b/Application/Validators/Service/CreateServiceValidator.cs index 077be06..b842c9b 100644 --- a/Application/Validators/Service/CreateServiceValidator.cs +++ b/Application/Validators/Service/CreateServiceValidator.cs @@ -3,7 +3,7 @@ namespace Application.Validators.Service; -public class CreateServiceValidator:AbstractValidator +public class CreateServiceValidator:AbstractValidator { public CreateServiceValidator() { diff --git a/Domain/Domain.csproj b/Domain/Domain.csproj index d3f8bd4..877932b 100644 --- a/Domain/Domain.csproj +++ b/Domain/Domain.csproj @@ -7,4 +7,8 @@ Linux + + + + diff --git a/Domain/Entities/Appointment.cs b/Domain/Entities/Appointment.cs index b1103c7..da15ea9 100644 --- a/Domain/Entities/Appointment.cs +++ b/Domain/Entities/Appointment.cs @@ -14,12 +14,12 @@ public class Appointment:TenantEntityContract public AppointmentStatus Status { get; private set; } public Guid TenantId { get; set; } public Tenant Tenant { get; set; } - + public ICollection Payments { get; set; } public ICollection AppointmentServices { get; private set; } = new List(); private Appointment() { } // For EF - public Appointment(Guid userId, DateTime startTime, DateTime endTime, string title) + public Appointment(Guid userId, DateTime startTime, DateTime endTime, string title,Guid tenantId) { if (startTime == default || endTime == default) throw new ArgumentException("StartTime and EndTime are required"); @@ -31,6 +31,7 @@ public Appointment(Guid userId, DateTime startTime, DateTime endTime, string tit StartTime = startTime; EndTime = endTime; Title = title; + TenantId = tenantId; Status = AppointmentStatus.Reserved; } diff --git a/Domain/Entities/Payment.cs b/Domain/Entities/Payment.cs new file mode 100644 index 0000000..e538151 --- /dev/null +++ b/Domain/Entities/Payment.cs @@ -0,0 +1,67 @@ +using System.Runtime.CompilerServices; +using Microsoft.EntityFrameworkCore; + +namespace Domain.Entities; + +public class Payment +{ + public Guid Id { get; set; } + public Guid TenantId { get; set; } + public Guid AppointmentId { get; set; } + public Appointment apppointment { get; set; } + public string? State { get; set; } + public long Amount { get; set; } + public string? Wage { get; set; } + public string ResNum { get; set; } + + public string? RefNum { get; set; } + + public string? TraceNo { get; set; } + + public string? RRN { get; set; } + + public string? CardNumber { get; set; } + + public PaymentStatus PaymentStatus{ get; set; } + public int? PaymentGatewayStatus { get; set; } + + public DateTime CreatedAt { get; set; } + + public DateTime? PaidAt { get; set; } + + public Payment(Guid tenantId, Guid appointmentId, long amount, string resNum) + { + Id = Guid.NewGuid(); + TenantId = tenantId; + AppointmentId = appointmentId; + Amount = amount; + ResNum = resNum; + PaymentStatus = PaymentStatus.pending; + CreatedAt = DateTime.UtcNow; + } + + public void Edit(int? paymentGatewayStatus, string? state, string? _RRN, string? refNum, string? resNum, string? traceNo, long amount, string? wage) + { + PaymentGatewayStatus= paymentGatewayStatus; + State=state; + RRN=_RRN; + RefNum=refNum; + ResNum=resNum; + TraceNo=traceNo; + Amount = amount; + Wage=wage; + } + + void SetPaidAt() + { + PaidAt = DateTime.UtcNow; + } +} + +public enum PaymentStatus +{ + pending, + Success, + Failed, + Expired +} \ No newline at end of file diff --git a/Domain/Entities/Service.cs b/Domain/Entities/Service.cs index cd458fc..f14d01a 100644 --- a/Domain/Entities/Service.cs +++ b/Domain/Entities/Service.cs @@ -13,7 +13,7 @@ public class Service:TenantEntityContract private Service() { } - public Service(string title, int durationMinutes) + public Service(string title, int durationMinutes, Guid tenantId) { if (durationMinutes <= 0) throw new ArgumentException("Invalid duration"); @@ -21,6 +21,7 @@ public Service(string title, int durationMinutes) Id = Guid.NewGuid(); Title = title; DurationMinutes = durationMinutes; + TenantId= tenantId; } public void Edit(int durationMinutes,string title) diff --git a/Infrastructure/InfrastructureServices.cs b/Infrastructure/InfrastructureServices.cs index 20c64df..64a9d90 100644 --- a/Infrastructure/InfrastructureServices.cs +++ b/Infrastructure/InfrastructureServices.cs @@ -4,6 +4,7 @@ using Application.Features.Appointments.Interfaces; using Application.Features.AppointmentServiceLink.Interfaces; using Application.Features.Auth.Interfaces; +using Application.Features.Payment.Interfaces; using Application.Features.Service.Interfaces; using Application.Features.Tenant.Interfaces; using Infrastructure.Persistence; @@ -36,6 +37,7 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/Infrastructure/Migrations/20260603120331_Payment.Designer.cs b/Infrastructure/Migrations/20260603120331_Payment.Designer.cs new file mode 100644 index 0000000..477c7d1 --- /dev/null +++ b/Infrastructure/Migrations/20260603120331_Payment.Designer.cs @@ -0,0 +1,391 @@ +// +using System; +using Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260603120331_Payment")] + partial class Payment + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Domain.Entities.Appointment", b => + { + b.Property("AppointmentId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("EndTime") + .HasColumnType("datetime2"); + + b.Property("StartTime") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("AppointmentId"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.ToTable("Appointments", t => + { + t.HasCheckConstraint("CK_Status_Valid_Values", "[Status] IN (0, 1,2)"); + }); + }); + + modelBuilder.Entity("Domain.Entities.AppointmentServiceLink", b => + { + b.Property("AppointmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("ServiceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("AppointmentId", "ServiceId"); + + b.HasIndex("ServiceId"); + + b.ToTable("AppointmentServiceLinks", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Payment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Amount") + .HasColumnType("bigint"); + + b.Property("AppointmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CardNumber") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("PaidAt") + .HasColumnType("datetime2"); + + b.Property("RRN") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RefNum") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ResNum") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("TraceNo") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AppointmentId"); + + b.HasIndex("ResNum") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("Payments", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsRevoked") + .HasColumnType("bit"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("Domain.Entities.Service", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("DurationMinutes") + .HasColumnType("int"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("Service", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("DateJoined") + .HasColumnType("datetime2"); + + b.Property("ExpireSubscriptionDate") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(true) + .HasColumnType("nvarchar(200)"); + + b.Property("SubscriptionStatus") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Tenants", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Password") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("PasswordResetAttemptsCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("PasswordResetCodeExpireAt") + .HasColumnType("datetime2"); + + b.Property("PasswordResetCodeHash") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("PhoneNumber") + .IsRequired() + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("Users", null, t => + { + t.HasCheckConstraint("CK_Role_Valid_Values", "[Role] IN (0,1,2,3)"); + }); + }); + + modelBuilder.Entity("Domain.Entities.Appointment", b => + { + b.HasOne("Domain.Entities.Tenant", "Tenant") + .WithMany("Appointments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Domain.Entities.User", "User") + .WithMany("Appointments") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.AppointmentServiceLink", b => + { + b.HasOne("Domain.Entities.Appointment", "Appointment") + .WithMany("AppointmentServices") + .HasForeignKey("AppointmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Domain.Entities.Service", "Service") + .WithMany("AppointmentServices") + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Appointment"); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("Domain.Entities.Payment", b => + { + b.HasOne("Domain.Entities.Appointment", "apppointment") + .WithMany("Payments") + .HasForeignKey("AppointmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("apppointment"); + }); + + modelBuilder.Entity("Domain.Entities.RefreshToken", b => + { + b.HasOne("Domain.Entities.User", "User") + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.Service", b => + { + b.HasOne("Domain.Entities.Tenant", "Tenant") + .WithMany("Services") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.HasOne("Domain.Entities.Tenant", "Tenant") + .WithMany("Users") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Domain.Entities.Appointment", b => + { + b.Navigation("AppointmentServices"); + + b.Navigation("Payments"); + }); + + modelBuilder.Entity("Domain.Entities.Service", b => + { + b.Navigation("AppointmentServices"); + }); + + modelBuilder.Entity("Domain.Entities.Tenant", b => + { + b.Navigation("Appointments"); + + b.Navigation("Services"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Navigation("Appointments"); + + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Infrastructure/Migrations/20260603120331_Payment.cs b/Infrastructure/Migrations/20260603120331_Payment.cs new file mode 100644 index 0000000..61799a1 --- /dev/null +++ b/Infrastructure/Migrations/20260603120331_Payment.cs @@ -0,0 +1,66 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Migrations +{ + /// + public partial class Payment : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Payments", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + TenantId = table.Column(type: "uniqueidentifier", nullable: false), + AppointmentId = table.Column(type: "uniqueidentifier", nullable: false), + Amount = table.Column(type: "bigint", nullable: false), + ResNum = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + RefNum = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + TraceNo = table.Column(type: "nvarchar(max)", nullable: false), + RRN = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + CardNumber = table.Column(type: "nvarchar(20)", maxLength: 20, nullable: true), + Status = table.Column(type: "int", nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false), + PaidAt = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Payments", x => x.Id); + table.ForeignKey( + name: "FK_Payments_Appointments_AppointmentId", + column: x => x.AppointmentId, + principalTable: "Appointments", + principalColumn: "AppointmentId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Payments_AppointmentId", + table: "Payments", + column: "AppointmentId"); + + migrationBuilder.CreateIndex( + name: "IX_Payments_ResNum", + table: "Payments", + column: "ResNum", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Payments_TenantId", + table: "Payments", + column: "TenantId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Payments"); + } + } +} diff --git a/Infrastructure/Migrations/20260606121723_TraceNoNullable.Designer.cs b/Infrastructure/Migrations/20260606121723_TraceNoNullable.Designer.cs new file mode 100644 index 0000000..8bc69e3 --- /dev/null +++ b/Infrastructure/Migrations/20260606121723_TraceNoNullable.Designer.cs @@ -0,0 +1,390 @@ +// +using System; +using Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260606121723_TraceNoNullable")] + partial class TraceNoNullable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Domain.Entities.Appointment", b => + { + b.Property("AppointmentId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("EndTime") + .HasColumnType("datetime2"); + + b.Property("StartTime") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("AppointmentId"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.ToTable("Appointments", t => + { + t.HasCheckConstraint("CK_Status_Valid_Values", "[Status] IN (0, 1,2)"); + }); + }); + + modelBuilder.Entity("Domain.Entities.AppointmentServiceLink", b => + { + b.Property("AppointmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("ServiceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("AppointmentId", "ServiceId"); + + b.HasIndex("ServiceId"); + + b.ToTable("AppointmentServiceLinks", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Payment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Amount") + .HasColumnType("bigint"); + + b.Property("AppointmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CardNumber") + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("PaidAt") + .HasColumnType("datetime2"); + + b.Property("RRN") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RefNum") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ResNum") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("TraceNo") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AppointmentId"); + + b.HasIndex("ResNum") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("Payments", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsRevoked") + .HasColumnType("bit"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("Domain.Entities.Service", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("DurationMinutes") + .HasColumnType("int"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("Service", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("DateJoined") + .HasColumnType("datetime2"); + + b.Property("ExpireSubscriptionDate") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(true) + .HasColumnType("nvarchar(200)"); + + b.Property("SubscriptionStatus") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Tenants", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Password") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("PasswordResetAttemptsCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("PasswordResetCodeExpireAt") + .HasColumnType("datetime2"); + + b.Property("PasswordResetCodeHash") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("PhoneNumber") + .IsRequired() + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("Users", null, t => + { + t.HasCheckConstraint("CK_Role_Valid_Values", "[Role] IN (0,1,2,3)"); + }); + }); + + modelBuilder.Entity("Domain.Entities.Appointment", b => + { + b.HasOne("Domain.Entities.Tenant", "Tenant") + .WithMany("Appointments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Domain.Entities.User", "User") + .WithMany("Appointments") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.AppointmentServiceLink", b => + { + b.HasOne("Domain.Entities.Appointment", "Appointment") + .WithMany("AppointmentServices") + .HasForeignKey("AppointmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Domain.Entities.Service", "Service") + .WithMany("AppointmentServices") + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Appointment"); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("Domain.Entities.Payment", b => + { + b.HasOne("Domain.Entities.Appointment", "apppointment") + .WithMany("Payments") + .HasForeignKey("AppointmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("apppointment"); + }); + + modelBuilder.Entity("Domain.Entities.RefreshToken", b => + { + b.HasOne("Domain.Entities.User", "User") + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.Service", b => + { + b.HasOne("Domain.Entities.Tenant", "Tenant") + .WithMany("Services") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.HasOne("Domain.Entities.Tenant", "Tenant") + .WithMany("Users") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Domain.Entities.Appointment", b => + { + b.Navigation("AppointmentServices"); + + b.Navigation("Payments"); + }); + + modelBuilder.Entity("Domain.Entities.Service", b => + { + b.Navigation("AppointmentServices"); + }); + + modelBuilder.Entity("Domain.Entities.Tenant", b => + { + b.Navigation("Appointments"); + + b.Navigation("Services"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Navigation("Appointments"); + + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Infrastructure/Migrations/20260606121723_TraceNoNullable.cs b/Infrastructure/Migrations/20260606121723_TraceNoNullable.cs new file mode 100644 index 0000000..f83994a --- /dev/null +++ b/Infrastructure/Migrations/20260606121723_TraceNoNullable.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Migrations +{ + /// + public partial class TraceNoNullable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "TraceNo", + table: "Payments", + type: "nvarchar(max)", + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(max)"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "TraceNo", + table: "Payments", + type: "nvarchar(max)", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "nvarchar(max)", + oldNullable: true); + } + } +} diff --git a/Infrastructure/Migrations/20260606222719_FixingPaymentTable.Designer.cs b/Infrastructure/Migrations/20260606222719_FixingPaymentTable.Designer.cs new file mode 100644 index 0000000..c1c0374 --- /dev/null +++ b/Infrastructure/Migrations/20260606222719_FixingPaymentTable.Designer.cs @@ -0,0 +1,398 @@ +// +using System; +using Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260606222719_FixingPaymentTable")] + partial class FixingPaymentTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Domain.Entities.Appointment", b => + { + b.Property("AppointmentId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("EndTime") + .HasColumnType("datetime2"); + + b.Property("StartTime") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("AppointmentId"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.ToTable("Appointments", t => + { + t.HasCheckConstraint("CK_Status_Valid_Values", "[Status] IN (0, 1,2)"); + }); + }); + + modelBuilder.Entity("Domain.Entities.AppointmentServiceLink", b => + { + b.Property("AppointmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("ServiceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("AppointmentId", "ServiceId"); + + b.HasIndex("ServiceId"); + + b.ToTable("AppointmentServiceLinks", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Payment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Amount") + .HasColumnType("bigint"); + + b.Property("AppointmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CardNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("PaidAt") + .HasColumnType("datetime2"); + + b.Property("PaymentGatewayStatus") + .HasColumnType("int"); + + b.Property("PaymentStatus") + .HasColumnType("int"); + + b.Property("RRN") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RefNum") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ResNum") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("TraceNo") + .HasColumnType("nvarchar(max)"); + + b.Property("Wage") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AppointmentId"); + + b.HasIndex("ResNum") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("Payments", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsRevoked") + .HasColumnType("bit"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("Domain.Entities.Service", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("DurationMinutes") + .HasColumnType("int"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("Service", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("DateJoined") + .HasColumnType("datetime2"); + + b.Property("ExpireSubscriptionDate") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(true) + .HasColumnType("nvarchar(200)"); + + b.Property("SubscriptionStatus") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Tenants", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Password") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("PasswordResetAttemptsCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("PasswordResetCodeExpireAt") + .HasColumnType("datetime2"); + + b.Property("PasswordResetCodeHash") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("PhoneNumber") + .IsRequired() + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("Users", null, t => + { + t.HasCheckConstraint("CK_Role_Valid_Values", "[Role] IN (0,1,2,3)"); + }); + }); + + modelBuilder.Entity("Domain.Entities.Appointment", b => + { + b.HasOne("Domain.Entities.Tenant", "Tenant") + .WithMany("Appointments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Domain.Entities.User", "User") + .WithMany("Appointments") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.AppointmentServiceLink", b => + { + b.HasOne("Domain.Entities.Appointment", "Appointment") + .WithMany("AppointmentServices") + .HasForeignKey("AppointmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Domain.Entities.Service", "Service") + .WithMany("AppointmentServices") + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Appointment"); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("Domain.Entities.Payment", b => + { + b.HasOne("Domain.Entities.Appointment", "apppointment") + .WithMany("Payments") + .HasForeignKey("AppointmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("apppointment"); + }); + + modelBuilder.Entity("Domain.Entities.RefreshToken", b => + { + b.HasOne("Domain.Entities.User", "User") + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.Service", b => + { + b.HasOne("Domain.Entities.Tenant", "Tenant") + .WithMany("Services") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.HasOne("Domain.Entities.Tenant", "Tenant") + .WithMany("Users") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Domain.Entities.Appointment", b => + { + b.Navigation("AppointmentServices"); + + b.Navigation("Payments"); + }); + + modelBuilder.Entity("Domain.Entities.Service", b => + { + b.Navigation("AppointmentServices"); + }); + + modelBuilder.Entity("Domain.Entities.Tenant", b => + { + b.Navigation("Appointments"); + + b.Navigation("Services"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Navigation("Appointments"); + + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Infrastructure/Migrations/20260606222719_FixingPaymentTable.cs b/Infrastructure/Migrations/20260606222719_FixingPaymentTable.cs new file mode 100644 index 0000000..4799925 --- /dev/null +++ b/Infrastructure/Migrations/20260606222719_FixingPaymentTable.cs @@ -0,0 +1,78 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Migrations +{ + /// + public partial class FixingPaymentTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "Status", + table: "Payments", + newName: "PaymentStatus"); + + migrationBuilder.AlterColumn( + name: "CardNumber", + table: "Payments", + type: "nvarchar(max)", + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(20)", + oldMaxLength: 20, + oldNullable: true); + + migrationBuilder.AddColumn( + name: "PaymentGatewayStatus", + table: "Payments", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "State", + table: "Payments", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "Wage", + table: "Payments", + type: "nvarchar(max)", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PaymentGatewayStatus", + table: "Payments"); + + migrationBuilder.DropColumn( + name: "State", + table: "Payments"); + + migrationBuilder.DropColumn( + name: "Wage", + table: "Payments"); + + migrationBuilder.RenameColumn( + name: "PaymentStatus", + table: "Payments", + newName: "Status"); + + migrationBuilder.AlterColumn( + name: "CardNumber", + table: "Payments", + type: "nvarchar(20)", + maxLength: 20, + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(max)", + oldNullable: true); + } + } +} diff --git a/Infrastructure/Persistence/AppDbContext.cs b/Infrastructure/Persistence/AppDbContext.cs index 5d72d3b..60b5964 100644 --- a/Infrastructure/Persistence/AppDbContext.cs +++ b/Infrastructure/Persistence/AppDbContext.cs @@ -11,6 +11,7 @@ public class AppDbContext:DbContext public DbSet Services =>Set(); public DbSet Users =>Set(); public DbSet RefreshTokens =>Set(); + public DbSet Payments { get; set; } public DbSet AppointmentServiceLinks =>Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) diff --git a/Infrastructure/Persistence/Configurations/PaymentConfiguration.cs b/Infrastructure/Persistence/Configurations/PaymentConfiguration.cs new file mode 100644 index 0000000..c756f4c --- /dev/null +++ b/Infrastructure/Persistence/Configurations/PaymentConfiguration.cs @@ -0,0 +1,66 @@ +using Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Infrastructure.Persistence.Configurations; + +public class PaymentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("Payments"); + + builder.HasKey(p => p.Id); + + builder.Property(p => p.TenantId).IsRequired(); + + builder.Property(p => p.AppointmentId).IsRequired(); + + builder + .Property(p => p.Amount) + .IsRequired(); + + builder.Property(p=>p.ResNum) + .HasMaxLength(100) + .IsRequired(); + + builder.Property(p => p.RefNum) + .HasMaxLength(100); + + builder + .Property(p => p.TraceNo); + + builder + .Property(p=>p.RRN) + .HasMaxLength(100); + + builder + .Property(p=>p.PaymentStatus) + .IsRequired(); + + builder + .Property(p=>p.PaymentGatewayStatus); + + builder + .Property(p => p.CreatedAt) + .IsRequired(); + + builder + .Property(p=>p.PaidAt) + .IsRequired(false); + + builder.HasOne(p => p.apppointment) + .WithMany(p => p.Payments) + .HasForeignKey(p => p.AppointmentId); + + builder + .HasIndex(p=>p.ResNum) + .IsUnique(); + + builder + .HasIndex(p => p.AppointmentId); + + builder + .HasIndex(p => p.TenantId); + } +} \ No newline at end of file diff --git a/Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index d8f7931..d1c5ed9 100644 --- a/Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -75,6 +75,70 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AppointmentServiceLinks", (string)null); }); + modelBuilder.Entity("Domain.Entities.Payment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Amount") + .HasColumnType("bigint"); + + b.Property("AppointmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CardNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("PaidAt") + .HasColumnType("datetime2"); + + b.Property("PaymentGatewayStatus") + .HasColumnType("int"); + + b.Property("PaymentStatus") + .HasColumnType("int"); + + b.Property("RRN") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RefNum") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ResNum") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("TraceNo") + .HasColumnType("nvarchar(max)"); + + b.Property("Wage") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AppointmentId"); + + b.HasIndex("ResNum") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("Payments", (string)null); + }); + modelBuilder.Entity("Domain.Entities.RefreshToken", b => { b.Property("Id") @@ -255,6 +319,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Service"); }); + modelBuilder.Entity("Domain.Entities.Payment", b => + { + b.HasOne("Domain.Entities.Appointment", "apppointment") + .WithMany("Payments") + .HasForeignKey("AppointmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("apppointment"); + }); + modelBuilder.Entity("Domain.Entities.RefreshToken", b => { b.HasOne("Domain.Entities.User", "User") @@ -290,6 +365,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Domain.Entities.Appointment", b => { b.Navigation("AppointmentServices"); + + b.Navigation("Payments"); }); modelBuilder.Entity("Domain.Entities.Service", b => diff --git a/Infrastructure/Persistence/Repositories/PaymentRepository.cs b/Infrastructure/Persistence/Repositories/PaymentRepository.cs new file mode 100644 index 0000000..3cff37f --- /dev/null +++ b/Infrastructure/Persistence/Repositories/PaymentRepository.cs @@ -0,0 +1,40 @@ +using Application.Features.Payment.Interfaces; +using Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Infrastructure.Persistence.Repositories; + +public class PaymentRepository:PaymentRepositoryContract +{ + private readonly AppDbContext _dbContext; + + public PaymentRepository(AppDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task CreatePaymentAsync(Payment payment) + { + await _dbContext + .Payments + .AddAsync(payment); + } + + public async Task SaveAsync() + { + await _dbContext.SaveChangesAsync(); + } + + public async Task GetPaymentByResNumAsync(string resNum) + { + return await _dbContext + .Payments + .Where(p => p.ResNum == resNum) + .FirstOrDefaultAsync(); + } + + public Task IsExistByRefNum(string refNum) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/api/Controllers/PaymentController.cs b/api/Controllers/PaymentController.cs index 497fdc9..b079c30 100644 --- a/api/Controllers/PaymentController.cs +++ b/api/Controllers/PaymentController.cs @@ -14,7 +14,8 @@ public class PaymentController : ControllerBase private readonly IConfiguration _config; private readonly PaymentServiceContract _paymentServiceContract; - public PaymentController(HttpClient httpClient, IConfiguration config, PaymentServiceContract paymentServiceContract) + public PaymentController(HttpClient httpClient, IConfiguration config, + PaymentServiceContract paymentServiceContract) { _httpClient = httpClient; _config = config; @@ -22,26 +23,26 @@ public PaymentController(HttpClient httpClient, IConfiguration config, PaymentSe } [HttpPost] - public async Task RequestToken([FromBody]CreatePaymentDto dto) + public async Task RequestToken([FromBody] CreatePaymentDto dto) { - var resNum = await _paymentServiceContract.GenerateResNum(); + var resNum = await _paymentServiceContract.CreatePaymentAsync(dto); var requestBody = new { - action="token", - TerminalId=_config["Payment:TerminalId"], - Amount=dto.Amount, - ResNum=resNum, - RedirectUrl=_config["Payment:RedirectUrl"], - CellNumber=dto.PhoneNumber + action = "token", + TerminalId = _config["Payment:TerminalId"], + Amount = dto.Amount, + ResNum = resNum, + RedirectUrl = "http://salon1.localhost:8596/api/Payment/CallBack" }; - - var content=new StringContent( + + var content = new StringContent( JsonSerializer.Serialize(requestBody), Encoding.UTF8, "application/json"); - - var response=await _httpClient.PostAsync("https://sandbox.banktest.ir/saman/sep.shaparak.ir/OnlinePG/OnlinePG",content); - + + var response = + await _httpClient.PostAsync("https://sandbox.banktest.ir/saman/sep.shaparak.ir/OnlinePG/OnlinePG", content); + var result = await response.Content.ReadFromJsonAsync(); if (result is null || result.status != 1) @@ -52,6 +53,28 @@ public async Task RequestToken([FromBody]CreatePaymentDto dto) PayUrl = $"https://sandbox.banktest.ir/saman/sep.shaparak.ir/OnlinePG/SendToken?token={result.token}" }); } + [HttpPost] - public + public async Task CallBack() + { + var response=Request.Form.ToDictionary(x=>x.Key, x=>x.Value); + var dto = new SandBoxCallBackDto + { + MID = response.GetValueOrDefault("MID"), + Status = int.Parse(response.GetValueOrDefault("Status")), + State = response.GetValueOrDefault("State"), + RRN = response.GetValueOrDefault("RRN"), + RefNum = response.GetValueOrDefault("RefNum"), + ResNum = response.GetValueOrDefault("ResNum"), + TraceNo = response.GetValueOrDefault("TraceNo"), + Amount = long.Parse(response.GetValueOrDefault("Amount")), + Wage = response.GetValueOrDefault("Wage"), + CID = response.GetValueOrDefault("CID"), + SecurePan = response.GetValueOrDefault("SecurePan"), + Token = response.GetValueOrDefault("Token"), + }; + await _paymentServiceContract.ProccessCallBack(dto); + return Ok(); + } + } \ No newline at end of file diff --git a/api/Controllers/ServiceController.cs b/api/Controllers/ServiceController.cs index d77250d..fa47401 100644 --- a/api/Controllers/ServiceController.cs +++ b/api/Controllers/ServiceController.cs @@ -17,9 +17,9 @@ public ServiceController(IServiceAppService serviceAppService) [HttpPost] [Authorize(Roles = "SuperAdmin")] - public async Task Create([FromBody] CreateService service) + public async Task Create([FromBody]CreateServiceDto serviceDto) { - var res=await _serviceAppService.CreateServiceAsync(service); + var res=await _serviceAppService.CreateServiceAsync(serviceDto); return Ok(res); } From 98a480279abea1361c0cb2e0fb2600cb9a37c107 Mon Sep 17 00:00:00 2001 From: Nima Haji Date: Sun, 7 Jun 2026 18:31:55 +0330 Subject: [PATCH 2/4] VerifyTransaction implemented --- .../Interfaces/PaymentServiceContract.cs | 1 + .../Payment/Services/PaymentService.cs | 45 +++++++++++++++++-- Domain/Entities/Payment.cs | 8 ++++ api/Controllers/PaymentController.cs | 2 +- 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/Application/Features/Payment/Interfaces/PaymentServiceContract.cs b/Application/Features/Payment/Interfaces/PaymentServiceContract.cs index 9293251..d1dace7 100644 --- a/Application/Features/Payment/Interfaces/PaymentServiceContract.cs +++ b/Application/Features/Payment/Interfaces/PaymentServiceContract.cs @@ -7,4 +7,5 @@ public interface PaymentServiceContract Task GenerateResNum(); Task CreatePaymentAsync(CreatePaymentDto dto); Task ProccessCallBack(SandBoxCallBackDto dto); + Task VerifyTransaction(string RefNum); } \ No newline at end of file diff --git a/Application/Features/Payment/Services/PaymentService.cs b/Application/Features/Payment/Services/PaymentService.cs index a67ed38..2919811 100644 --- a/Application/Features/Payment/Services/PaymentService.cs +++ b/Application/Features/Payment/Services/PaymentService.cs @@ -1,16 +1,21 @@ +using System.Net.Http.Json; using System.Security.Cryptography; using Application.Features.Payment.DTOs; using Application.Features.Payment.Interfaces; +using Microsoft.Extensions.Configuration; namespace Application.Features.Payment.Services; public class PaymentService : PaymentServiceContract { private readonly PaymentRepositoryContract _paymentRepository; - - public PaymentService(PaymentRepositoryContract paymentRepository) + private readonly IConfiguration _configuration; + private readonly HttpClient _httpClient; + public PaymentService(PaymentRepositoryContract paymentRepository, IConfiguration configuration, HttpClient httpClient) { _paymentRepository = paymentRepository; + _configuration = configuration; + _httpClient = httpClient; } public async Task GenerateResNum() @@ -39,9 +44,41 @@ public async Task ProccessCallBack(SandBoxCallBackDto dto) if (payment == null) throw new Exception("تراکنش نامعتبر"); payment.Edit(dto.Status, dto.State, dto.RRN, dto.RefNum, dto.ResNum, dto.TraceNo, dto.Amount, dto.Wage); - var isValid = dto.State != "OK" || dto.Status != 2; + + if (dto.State != "OK" || dto.Status != 2) + { + payment.MarkAsFailed(); + await _paymentRepository.SaveAsync(); + return false; + } + + var verifyTransaction= await VerifyTransaction(dto.RefNum); + if (!verifyTransaction) + { + payment.MarkAsFailed(); + await _paymentRepository.SaveAsync(); + return false; + } + payment.MarkAsSuccess(); await _paymentRepository.SaveAsync(); + return true; + } + + public async Task VerifyTransaction(string RefNum) + { + var request = new + { + refNum = RefNum, + TerminalNumber =_configuration["Payment:TerminalId"] + }; + var verifyUrl=_configuration["Payment:VerifyTransactionUrl"]; + var response =await _httpClient.PostAsJsonAsync(verifyUrl,request); - return isValid; + if (!response.IsSuccessStatusCode) + return false; + + var result = await response.Content.ReadAsStringAsync(); + Console.WriteLine(result); + return result is not null; } } \ No newline at end of file diff --git a/Domain/Entities/Payment.cs b/Domain/Entities/Payment.cs index e538151..ed48450 100644 --- a/Domain/Entities/Payment.cs +++ b/Domain/Entities/Payment.cs @@ -56,6 +56,14 @@ void SetPaidAt() { PaidAt = DateTime.UtcNow; } + public void MarkAsFailed() + { + PaymentStatus = PaymentStatus.Failed; + } + public void MarkAsSuccess() + { + PaymentStatus = PaymentStatus.Failed; + } } public enum PaymentStatus diff --git a/api/Controllers/PaymentController.cs b/api/Controllers/PaymentController.cs index b079c30..854d7e4 100644 --- a/api/Controllers/PaymentController.cs +++ b/api/Controllers/PaymentController.cs @@ -76,5 +76,5 @@ public async Task CallBack() await _paymentServiceContract.ProccessCallBack(dto); return Ok(); } - + } \ No newline at end of file From 0d113162a29bd4b76aa21d6d3fecdea6ef9d7eec Mon Sep 17 00:00:00 2001 From: Nima Haji Date: Thu, 25 Jun 2026 17:37:24 +0330 Subject: [PATCH 3/4] ZarinPal payment Added --- .../DTOs/ZarinPal/CreateZarinPalPaymentDto.cs | 7 + .../DTOs/ZarinPal/ZarinPalCallBackResponse.cs | 7 + .../Payment/DTOs/ZarinPal/ZarinPalResponse.cs | 12 + .../DTOs/ZarinPal/ZarinPalVerifyResponse.cs | 14 + .../Payment/Services/PaymentService.cs | 4 +- Domain/Entities/Payment.cs | 1 + ...17155320_SecurePanAddedToTable.Designer.cs | 401 ++++++++++++++++++ .../20260617155320_SecurePanAddedToTable.cs | 28 ++ .../Configurations/PaymentConfiguration.cs | 3 + .../Migrations/AppDbContextModelSnapshot.cs | 3 + api/Controllers/PaymentController.cs | 55 ++- 11 files changed, 532 insertions(+), 3 deletions(-) create mode 100644 Application/Features/Payment/DTOs/ZarinPal/CreateZarinPalPaymentDto.cs create mode 100644 Application/Features/Payment/DTOs/ZarinPal/ZarinPalCallBackResponse.cs create mode 100644 Application/Features/Payment/DTOs/ZarinPal/ZarinPalResponse.cs create mode 100644 Application/Features/Payment/DTOs/ZarinPal/ZarinPalVerifyResponse.cs create mode 100644 Infrastructure/Migrations/20260617155320_SecurePanAddedToTable.Designer.cs create mode 100644 Infrastructure/Migrations/20260617155320_SecurePanAddedToTable.cs diff --git a/Application/Features/Payment/DTOs/ZarinPal/CreateZarinPalPaymentDto.cs b/Application/Features/Payment/DTOs/ZarinPal/CreateZarinPalPaymentDto.cs new file mode 100644 index 0000000..0021ebc --- /dev/null +++ b/Application/Features/Payment/DTOs/ZarinPal/CreateZarinPalPaymentDto.cs @@ -0,0 +1,7 @@ +namespace Application.Features.Payment.DTOs; + +public class CreateZarinPalPaymentDto +{ + public long Amount { get; set; } + public string Description { get; set; } +} \ No newline at end of file diff --git a/Application/Features/Payment/DTOs/ZarinPal/ZarinPalCallBackResponse.cs b/Application/Features/Payment/DTOs/ZarinPal/ZarinPalCallBackResponse.cs new file mode 100644 index 0000000..754ae22 --- /dev/null +++ b/Application/Features/Payment/DTOs/ZarinPal/ZarinPalCallBackResponse.cs @@ -0,0 +1,7 @@ +namespace Application.Features.Payment.DTOs; + +public class ZarinPalCallBackResponse +{ + public string Authority { get; set; } + public string Status { get; set; } +} \ No newline at end of file diff --git a/Application/Features/Payment/DTOs/ZarinPal/ZarinPalResponse.cs b/Application/Features/Payment/DTOs/ZarinPal/ZarinPalResponse.cs new file mode 100644 index 0000000..cd1e076 --- /dev/null +++ b/Application/Features/Payment/DTOs/ZarinPal/ZarinPalResponse.cs @@ -0,0 +1,12 @@ +namespace Application.Features.Payment.DTOs; + +public class ZarinPalResponse +{ + public ZarinPalData? Data { get; set; } + public object? Error { get; set; } +} + +public class ZarinPalData +{ + public string Authority { get; set; } +} \ No newline at end of file diff --git a/Application/Features/Payment/DTOs/ZarinPal/ZarinPalVerifyResponse.cs b/Application/Features/Payment/DTOs/ZarinPal/ZarinPalVerifyResponse.cs new file mode 100644 index 0000000..5224bc5 --- /dev/null +++ b/Application/Features/Payment/DTOs/ZarinPal/ZarinPalVerifyResponse.cs @@ -0,0 +1,14 @@ +namespace Application.Features.Payment.DTOs; + +public class ZarinPalVerifyResponse +{ + public ZarinPalVerifyData Data { get; set; } +} + +public class ZarinPalVerifyData +{ + public int Code { get; set; } + public string CardPan { get; set; } + public int RefId { get; set; } + public string CardHash { get; set; } +} \ No newline at end of file diff --git a/Application/Features/Payment/Services/PaymentService.cs b/Application/Features/Payment/Services/PaymentService.cs index 2919811..44941af 100644 --- a/Application/Features/Payment/Services/PaymentService.cs +++ b/Application/Features/Payment/Services/PaymentService.cs @@ -36,8 +36,8 @@ public async Task CreatePaymentAsync(CreatePaymentDto dto) public async Task ProccessCallBack(SandBoxCallBackDto dto) { - if (dto.State != "OK") - throw new Exception("پرداخت ناموفق"); + // if (dto.State != "OK") + // throw new Exception("پرداخت ناموفق"); if (dto.ResNum == null) throw new Exception("شماره رزرو نامعتبر است ."); var payment = await _paymentRepository.GetPaymentByResNumAsync(dto.ResNum); diff --git a/Domain/Entities/Payment.cs b/Domain/Entities/Payment.cs index ed48450..74a1034 100644 --- a/Domain/Entities/Payment.cs +++ b/Domain/Entities/Payment.cs @@ -24,6 +24,7 @@ public class Payment public PaymentStatus PaymentStatus{ get; set; } public int? PaymentGatewayStatus { get; set; } + public string? SecurePan { get; set; } public DateTime CreatedAt { get; set; } diff --git a/Infrastructure/Migrations/20260617155320_SecurePanAddedToTable.Designer.cs b/Infrastructure/Migrations/20260617155320_SecurePanAddedToTable.Designer.cs new file mode 100644 index 0000000..af88294 --- /dev/null +++ b/Infrastructure/Migrations/20260617155320_SecurePanAddedToTable.Designer.cs @@ -0,0 +1,401 @@ +// +using System; +using Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260617155320_SecurePanAddedToTable")] + partial class SecurePanAddedToTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Domain.Entities.Appointment", b => + { + b.Property("AppointmentId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("EndTime") + .HasColumnType("datetime2"); + + b.Property("StartTime") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("AppointmentId"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.ToTable("Appointments", t => + { + t.HasCheckConstraint("CK_Status_Valid_Values", "[Status] IN (0, 1,2)"); + }); + }); + + modelBuilder.Entity("Domain.Entities.AppointmentServiceLink", b => + { + b.Property("AppointmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("ServiceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("AppointmentId", "ServiceId"); + + b.HasIndex("ServiceId"); + + b.ToTable("AppointmentServiceLinks", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Payment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Amount") + .HasColumnType("bigint"); + + b.Property("AppointmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CardNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("PaidAt") + .HasColumnType("datetime2"); + + b.Property("PaymentGatewayStatus") + .HasColumnType("int"); + + b.Property("PaymentStatus") + .HasColumnType("int"); + + b.Property("RRN") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RefNum") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ResNum") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("SecurePan") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("TraceNo") + .HasColumnType("nvarchar(max)"); + + b.Property("Wage") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AppointmentId"); + + b.HasIndex("ResNum") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("Payments", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsRevoked") + .HasColumnType("bit"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("Domain.Entities.Service", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("DurationMinutes") + .HasColumnType("int"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("Service", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("DateJoined") + .HasColumnType("datetime2"); + + b.Property("ExpireSubscriptionDate") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(true) + .HasColumnType("nvarchar(200)"); + + b.Property("SubscriptionStatus") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Tenants", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Password") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("PasswordResetAttemptsCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("PasswordResetCodeExpireAt") + .HasColumnType("datetime2"); + + b.Property("PasswordResetCodeHash") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("PhoneNumber") + .IsRequired() + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("Users", null, t => + { + t.HasCheckConstraint("CK_Role_Valid_Values", "[Role] IN (0,1,2,3)"); + }); + }); + + modelBuilder.Entity("Domain.Entities.Appointment", b => + { + b.HasOne("Domain.Entities.Tenant", "Tenant") + .WithMany("Appointments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Domain.Entities.User", "User") + .WithMany("Appointments") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.AppointmentServiceLink", b => + { + b.HasOne("Domain.Entities.Appointment", "Appointment") + .WithMany("AppointmentServices") + .HasForeignKey("AppointmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Domain.Entities.Service", "Service") + .WithMany("AppointmentServices") + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Appointment"); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("Domain.Entities.Payment", b => + { + b.HasOne("Domain.Entities.Appointment", "apppointment") + .WithMany("Payments") + .HasForeignKey("AppointmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("apppointment"); + }); + + modelBuilder.Entity("Domain.Entities.RefreshToken", b => + { + b.HasOne("Domain.Entities.User", "User") + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.Service", b => + { + b.HasOne("Domain.Entities.Tenant", "Tenant") + .WithMany("Services") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.HasOne("Domain.Entities.Tenant", "Tenant") + .WithMany("Users") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Domain.Entities.Appointment", b => + { + b.Navigation("AppointmentServices"); + + b.Navigation("Payments"); + }); + + modelBuilder.Entity("Domain.Entities.Service", b => + { + b.Navigation("AppointmentServices"); + }); + + modelBuilder.Entity("Domain.Entities.Tenant", b => + { + b.Navigation("Appointments"); + + b.Navigation("Services"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Navigation("Appointments"); + + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Infrastructure/Migrations/20260617155320_SecurePanAddedToTable.cs b/Infrastructure/Migrations/20260617155320_SecurePanAddedToTable.cs new file mode 100644 index 0000000..111c0ea --- /dev/null +++ b/Infrastructure/Migrations/20260617155320_SecurePanAddedToTable.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Migrations +{ + /// + public partial class SecurePanAddedToTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SecurePan", + table: "Payments", + type: "nvarchar(max)", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SecurePan", + table: "Payments"); + } + } +} diff --git a/Infrastructure/Persistence/Configurations/PaymentConfiguration.cs b/Infrastructure/Persistence/Configurations/PaymentConfiguration.cs index c756f4c..d70c48a 100644 --- a/Infrastructure/Persistence/Configurations/PaymentConfiguration.cs +++ b/Infrastructure/Persistence/Configurations/PaymentConfiguration.cs @@ -38,6 +38,9 @@ public void Configure(EntityTypeBuilder builder) .Property(p=>p.PaymentStatus) .IsRequired(); + builder + .Property(p => p.SecurePan); + builder .Property(p=>p.PaymentGatewayStatus); diff --git a/Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index d1c5ed9..76747ac 100644 --- a/Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -115,6 +115,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(100) .HasColumnType("nvarchar(100)"); + b.Property("SecurePan") + .HasColumnType("nvarchar(max)"); + b.Property("State") .HasColumnType("nvarchar(max)"); diff --git a/api/Controllers/PaymentController.cs b/api/Controllers/PaymentController.cs index 854d7e4..212bb90 100644 --- a/api/Controllers/PaymentController.cs +++ b/api/Controllers/PaymentController.cs @@ -13,7 +13,6 @@ public class PaymentController : ControllerBase private readonly HttpClient _httpClient; private readonly IConfiguration _config; private readonly PaymentServiceContract _paymentServiceContract; - public PaymentController(HttpClient httpClient, IConfiguration config, PaymentServiceContract paymentServiceContract) { @@ -54,6 +53,23 @@ public async Task RequestToken([FromBody] CreatePaymentDto dto) }); } + [HttpPost] + public async Task RequestTokenZarinPal([FromBody] CreateZarinPalPaymentDto dto) + { + var request = new + { + merchant_id = _config["Payment:MerchantIdZarinPal"], + amount = dto.Amount, + description = dto.Description, + callback_url = _config["Payment:ZarinPalCallBackUrl"] + }; + var response = await _httpClient.PostAsJsonAsync("https://sandbox.zarinpal.com/pg/v4/payment/request.json",request); + + var result = await response.Content.ReadFromJsonAsync(); + var paymentUrl = "https://sandbox.zarinpal.com/pg/StartPay/"+result?.Data.Authority; + + return Ok(paymentUrl); + } [HttpPost] public async Task CallBack() { @@ -77,4 +93,41 @@ public async Task CallBack() return Ok(); } + [HttpGet] + public async Task ZarinPalCallBack([FromQuery] ZarinPalCallBackResponse response) + { + if (response.Status!="OK") + { + return BadRequest(response); + } + + var verifyRequest = new + { + merchant_id = _config["Payment:MerchantIdZarinPal"], + //Todo : Read from db + amount = 50000, + authority = response.Authority + }; + var verifyResponse = await _httpClient.PostAsJsonAsync("https://sandbox.zarinpal.com/pg/v4/payment/verify.json",verifyRequest); + + var result = await verifyResponse.Content.ReadFromJsonAsync(); + + // http://salon1.localhost:8596/api/Payment/ZarinPalCallBack?Authority=S00000000000000000000000000000ee1owr&Status=OK + if (result.Data.Code!=100) + return BadRequest(result); + + return Ok("پرداخت با موفقیت انجام شد شماره پیگیری "+result.Data.RefId); + } + + // [HttpPost] + // public async Task VerifyZarinPal() + // { + // var request=new + // { + // merchant_id = _config["Payment:MerchantIdZarinPal"], + // amount = dto.Amount, + // description = dto.Description, + // callback_url = _config["Payment:ZarinPalCallBackUrl"] + // } + // } } \ No newline at end of file From 272b3f8563adb3ba513240018f243ddb1ae56265 Mon Sep 17 00:00:00 2001 From: Nima Haji Date: Sun, 28 Jun 2026 23:28:20 +0330 Subject: [PATCH 4/4] Refactor to multi payment gateway --- Application/Application.csproj | 6 + Application/AssemblyReference.cs | 6 - .../Features/Payment/DTOs/CreatePaymentDto.cs | 9 + .../DTOs/Saman/SamanTokenRequestDto.cs | 14 + .../Payment/DTOs/Saman/VerifySamanPayment.cs | 19 + .../Payment/DTOs/SamanTokenResponseDto.cs | 15 + ...epCallBackDto.cs => SandBoxCallBackDto.cs} | 9 +- .../Features/Payment/DTOs/SepTokenResponse.cs | 12 - .../DTOs/ZarinPal/CreateZarinPalPaymentDto.cs | 23 +- .../DTOs/ZarinPal/ZarinPalCallBackResponse.cs | 2 +- .../Payment/DTOs/ZarinPal/ZarinPalResponse.cs | 9 +- .../DTOs/ZarinPal/ZarinPalVerifyResponse.cs | 10 +- .../PaymentGatewayProviderContract.cs | 14 + .../Interfaces/PaymentGatewayRequestResult.cs | 26 ++ .../PaymentGatewayResolverContract.cs | 8 + .../Interfaces/PaymentRepositoryContract.cs | 1 + .../Interfaces/PaymentServiceContract.cs | 8 +- .../Payment/Services/PaymentService.cs | 89 ++-- Domain/Entities/Payment.cs | 30 +- Domain/Entities/PaymentGateway.cs | 7 + Infrastructure/Infrastructure.csproj | 4 + Infrastructure/InfrastructureServices.cs | 4 + ...27153204_GatewayAddedToPayment.Designer.cs | 404 +++++++++++++++++ .../20260627153204_GatewayAddedToPayment.cs | 29 ++ ...54520_AuthorityAddedToPayments.Designer.cs | 407 +++++++++++++++++ ...20260628154520_AuthorityAddedToPayments.cs | 46 ++ ...138_DescriptionAddedToPayments.Designer.cs | 411 ++++++++++++++++++ ...260628195138_DescriptionAddedToPayments.cs | 29 ++ .../Configurations/PaymentConfiguration.cs | 10 + .../Migrations/AppDbContextModelSnapshot.cs | 14 +- .../Repositories/PaymentRepository.cs | 8 + .../Implement/PaymentGatewayResolver.cs | 24 + .../Implement/SamanPaymentGatewayProvider.cs | 93 ++++ .../ZarinPalPaymentGatewayProvider.cs | 99 +++++ api/Controllers/PaymentController.cs | 135 ++---- api/Program.cs | 1 + 36 files changed, 1859 insertions(+), 176 deletions(-) delete mode 100644 Application/AssemblyReference.cs create mode 100644 Application/Features/Payment/DTOs/Saman/SamanTokenRequestDto.cs create mode 100644 Application/Features/Payment/DTOs/Saman/VerifySamanPayment.cs create mode 100644 Application/Features/Payment/DTOs/SamanTokenResponseDto.cs rename Application/Features/Payment/DTOs/{SepCallBackDto.cs => SandBoxCallBackDto.cs} (65%) delete mode 100644 Application/Features/Payment/DTOs/SepTokenResponse.cs create mode 100644 Application/Features/Payment/Interfaces/PaymentGatewayProviderContract.cs create mode 100644 Application/Features/Payment/Interfaces/PaymentGatewayRequestResult.cs create mode 100644 Application/Features/Payment/Interfaces/PaymentGatewayResolverContract.cs create mode 100644 Domain/Entities/PaymentGateway.cs create mode 100644 Infrastructure/Migrations/20260627153204_GatewayAddedToPayment.Designer.cs create mode 100644 Infrastructure/Migrations/20260627153204_GatewayAddedToPayment.cs create mode 100644 Infrastructure/Migrations/20260628154520_AuthorityAddedToPayments.Designer.cs create mode 100644 Infrastructure/Migrations/20260628154520_AuthorityAddedToPayments.cs create mode 100644 Infrastructure/Migrations/20260628195138_DescriptionAddedToPayments.Designer.cs create mode 100644 Infrastructure/Migrations/20260628195138_DescriptionAddedToPayments.cs create mode 100644 Infrastructure/Services/Implement/PaymentGatewayResolver.cs create mode 100644 Infrastructure/Services/Implement/SamanPaymentGatewayProvider.cs create mode 100644 Infrastructure/Services/Implement/ZarinPalPaymentGatewayProvider.cs diff --git a/Application/Application.csproj b/Application/Application.csproj index fbeb5fd..07f49de 100644 --- a/Application/Application.csproj +++ b/Application/Application.csproj @@ -19,4 +19,10 @@ + + + C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App\8.0.23\Microsoft.AspNetCore.Http.Abstractions.dll + + + diff --git a/Application/AssemblyReference.cs b/Application/AssemblyReference.cs deleted file mode 100644 index 2c00c5e..0000000 --- a/Application/AssemblyReference.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Application; - -public sealed class AssemblyReference -{ - -} \ No newline at end of file diff --git a/Application/Features/Payment/DTOs/CreatePaymentDto.cs b/Application/Features/Payment/DTOs/CreatePaymentDto.cs index d495777..4698eac 100644 --- a/Application/Features/Payment/DTOs/CreatePaymentDto.cs +++ b/Application/Features/Payment/DTOs/CreatePaymentDto.cs @@ -7,4 +7,13 @@ public class CreatePaymentDto public Guid TenantId { get; set; } public Guid AppointmentId { get; set; } public long Amount { get; set; } + public PaymentGateway Gateway { get; set; } + + public string Description { get; set; } + public string? Mobile { get; set; } + public string? Email { get; set; } + + public decimal? GatewayFee { get; set; } + public int? TokenExpiryInMinutes { get; set; } + public string? ReferrerId { get; set; } } \ No newline at end of file diff --git a/Application/Features/Payment/DTOs/Saman/SamanTokenRequestDto.cs b/Application/Features/Payment/DTOs/Saman/SamanTokenRequestDto.cs new file mode 100644 index 0000000..4f4dd17 --- /dev/null +++ b/Application/Features/Payment/DTOs/Saman/SamanTokenRequestDto.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace Application.Features.Payment.DTOs.Saman; + +public class SamanTokenRequestDto +{ + [JsonPropertyName("Action")] public string? Action { get; set; } = "token"; + [JsonPropertyName("Amount")] public long Amount { get; set; } + [JsonPropertyName("Wage")] public decimal? Wage { get; set; } + [JsonPropertyName("ResNum")] public string? ResNum { get; set; } + [JsonPropertyName("CellNumber")] public string? CellNumber { get; set; } + [JsonPropertyName("TokenExpiryInMin")] public int TokenExpiryInMinutes { get; set; } + [JsonPropertyName("HashedCardNumber")] public string? HashedCardNumber { get; set; } +} \ No newline at end of file diff --git a/Application/Features/Payment/DTOs/Saman/VerifySamanPayment.cs b/Application/Features/Payment/DTOs/Saman/VerifySamanPayment.cs new file mode 100644 index 0000000..a33c2a4 --- /dev/null +++ b/Application/Features/Payment/DTOs/Saman/VerifySamanPayment.cs @@ -0,0 +1,19 @@ + +namespace Application.Features.Payment.DTOs.Saman; + +public class VerifySamanPayment +{ + public TransactionDetail? TransactionDetail { get; set; } + public int? ResultCode { get; set; } + public string? ResultDescription { get; set; } + public bool Success { get; set; } + +} + +public class TransactionDetail +{ + public string? RRN { get; set; } + public string? RefNum { get; set; } + public string? MaskedPan { get; set; } + public string? HashedPan { get; set; } +} \ No newline at end of file diff --git a/Application/Features/Payment/DTOs/SamanTokenResponseDto.cs b/Application/Features/Payment/DTOs/SamanTokenResponseDto.cs new file mode 100644 index 0000000..010cc72 --- /dev/null +++ b/Application/Features/Payment/DTOs/SamanTokenResponseDto.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Application.Features.Payment.DTOs; + +public class SamanTokenResponseDto +{ + [JsonPropertyName("status")] + public int Status { get; set; } + [JsonPropertyName("token")] + public string? Token { get; set; } + [JsonPropertyName("errorCode")] + public string? ErrorCode { get; set; } + [JsonPropertyName("errorDesc")] + public string? ErrorDescription { get; set; } +} \ No newline at end of file diff --git a/Application/Features/Payment/DTOs/SepCallBackDto.cs b/Application/Features/Payment/DTOs/SandBoxCallBackDto.cs similarity index 65% rename from Application/Features/Payment/DTOs/SepCallBackDto.cs rename to Application/Features/Payment/DTOs/SandBoxCallBackDto.cs index 847090e..024e028 100644 --- a/Application/Features/Payment/DTOs/SepCallBackDto.cs +++ b/Application/Features/Payment/DTOs/SandBoxCallBackDto.cs @@ -1,8 +1,11 @@ +namespace Application.Features.Payment.DTOs; + public class SandBoxCallBackDto { - public string? MID { get; set; } + public string? Authority { get; set; } + // public string? MID { get; set; } - public int? Status { get; set; } + public string? Status { get; set; } public string? State { get; set; } @@ -14,7 +17,7 @@ public class SandBoxCallBackDto public string? TraceNo { get; set; } - public long Amount { get; set; } + public long? Amount { get; set; } public string? Wage { get; set; } diff --git a/Application/Features/Payment/DTOs/SepTokenResponse.cs b/Application/Features/Payment/DTOs/SepTokenResponse.cs deleted file mode 100644 index fa59ff3..0000000 --- a/Application/Features/Payment/DTOs/SepTokenResponse.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Application.Features.Payment.DTOs; - -public class SepTokenResponse -{ - public int status { get; set; } - - public string? token { get; set; } - - public string? errorCode { get; set; } - - public string? errorDesc { get; set; } -} \ No newline at end of file diff --git a/Application/Features/Payment/DTOs/ZarinPal/CreateZarinPalPaymentDto.cs b/Application/Features/Payment/DTOs/ZarinPal/CreateZarinPalPaymentDto.cs index 0021ebc..7fbbec7 100644 --- a/Application/Features/Payment/DTOs/ZarinPal/CreateZarinPalPaymentDto.cs +++ b/Application/Features/Payment/DTOs/ZarinPal/CreateZarinPalPaymentDto.cs @@ -1,7 +1,26 @@ -namespace Application.Features.Payment.DTOs; +using System.Text.Json.Serialization; + +namespace Application.Features.Payment.DTOs.ZarinPal; public class CreateZarinPalPaymentDto -{ +{ + [JsonPropertyName("amount")] public long Amount { get; set; } + [JsonPropertyName("description")] public string Description { get; set; } + [JsonPropertyName("referrer_id")] + public string? ReferrerId { get; set; } + + [JsonPropertyName("matadata")] + public ZarinPalMataData ZarinPalMataData { get; set; } +} + +public class ZarinPalMataData +{ + [JsonPropertyName("mobile")] + public string? Mobile { get; set; } + [JsonPropertyName("email")] + public string? Email { get; set; } + [JsonPropertyName("order_id")] + public string? OrderId { get; set; } } \ No newline at end of file diff --git a/Application/Features/Payment/DTOs/ZarinPal/ZarinPalCallBackResponse.cs b/Application/Features/Payment/DTOs/ZarinPal/ZarinPalCallBackResponse.cs index 754ae22..858d2f7 100644 --- a/Application/Features/Payment/DTOs/ZarinPal/ZarinPalCallBackResponse.cs +++ b/Application/Features/Payment/DTOs/ZarinPal/ZarinPalCallBackResponse.cs @@ -1,4 +1,4 @@ -namespace Application.Features.Payment.DTOs; +namespace Application.Features.Payment.DTOs.ZarinPal; public class ZarinPalCallBackResponse { diff --git a/Application/Features/Payment/DTOs/ZarinPal/ZarinPalResponse.cs b/Application/Features/Payment/DTOs/ZarinPal/ZarinPalResponse.cs index cd1e076..a199e5a 100644 --- a/Application/Features/Payment/DTOs/ZarinPal/ZarinPalResponse.cs +++ b/Application/Features/Payment/DTOs/ZarinPal/ZarinPalResponse.cs @@ -1,12 +1,17 @@ -namespace Application.Features.Payment.DTOs; +using System.Text.Json.Serialization; + +namespace Application.Features.Payment.DTOs.ZarinPal; public class ZarinPalResponse -{ +{ + [JsonPropertyName("data")] public ZarinPalData? Data { get; set; } + [JsonPropertyName("errors")] public object? Error { get; set; } } public class ZarinPalData { + [JsonPropertyName("authority")] public string Authority { get; set; } } \ No newline at end of file diff --git a/Application/Features/Payment/DTOs/ZarinPal/ZarinPalVerifyResponse.cs b/Application/Features/Payment/DTOs/ZarinPal/ZarinPalVerifyResponse.cs index 5224bc5..acc3f4e 100644 --- a/Application/Features/Payment/DTOs/ZarinPal/ZarinPalVerifyResponse.cs +++ b/Application/Features/Payment/DTOs/ZarinPal/ZarinPalVerifyResponse.cs @@ -1,4 +1,6 @@ -namespace Application.Features.Payment.DTOs; +using System.Text.Json.Serialization; + +namespace Application.Features.Payment.DTOs.ZarinPal; public class ZarinPalVerifyResponse { @@ -7,8 +9,14 @@ public class ZarinPalVerifyResponse public class ZarinPalVerifyData { + [JsonPropertyName("code")] public int Code { get; set; } + [JsonPropertyName("card_pan")] public string CardPan { get; set; } + [JsonPropertyName("ref_id")] public int RefId { get; set; } + [JsonPropertyName("card_hash")] public string CardHash { get; set; } + [JsonPropertyName("fee")] + public int Fee { get; set; } } \ No newline at end of file diff --git a/Application/Features/Payment/Interfaces/PaymentGatewayProviderContract.cs b/Application/Features/Payment/Interfaces/PaymentGatewayProviderContract.cs new file mode 100644 index 0000000..714dd7f --- /dev/null +++ b/Application/Features/Payment/Interfaces/PaymentGatewayProviderContract.cs @@ -0,0 +1,14 @@ +using Application.Features.Payment.DTOs; +using Domain.Entities; +using Microsoft.AspNetCore.Http; + +namespace Application.Features.Payment.Interfaces; + +public interface PaymentGatewayProviderContract +{ + public PaymentGateway Gateway { get;} + + Task RequestPaymentAsync(Domain.Entities.Payment payment, CreatePaymentDto dto); + + Task HandleCallBackAsync(PaymentGateway gateway, SandBoxCallBackDto dto); +} \ No newline at end of file diff --git a/Application/Features/Payment/Interfaces/PaymentGatewayRequestResult.cs b/Application/Features/Payment/Interfaces/PaymentGatewayRequestResult.cs new file mode 100644 index 0000000..fb3ff83 --- /dev/null +++ b/Application/Features/Payment/Interfaces/PaymentGatewayRequestResult.cs @@ -0,0 +1,26 @@ +namespace Application.Features.Payment.Interfaces; + +public class PaymentGatewayRequestResult +{ + public bool IsSuccess { get; private set; } + public string? GatewayToken { get; private set; } + public string? PaymentUrl { get; private set; } + public string? ErrorCode { get; private set; } + public string? ErrorMessage { get; private set; } + + public static PaymentGatewayRequestResult Success(string gatewayToken, string paymentUrl) + => new() + { + IsSuccess = true, + GatewayToken = gatewayToken, + PaymentUrl = paymentUrl + }; + + public static PaymentGatewayRequestResult Failed(string? errorCode, string errorMessage) + => new() + { + IsSuccess = false, + ErrorCode = errorCode, + ErrorMessage = errorMessage + }; +} \ No newline at end of file diff --git a/Application/Features/Payment/Interfaces/PaymentGatewayResolverContract.cs b/Application/Features/Payment/Interfaces/PaymentGatewayResolverContract.cs new file mode 100644 index 0000000..3a9316b --- /dev/null +++ b/Application/Features/Payment/Interfaces/PaymentGatewayResolverContract.cs @@ -0,0 +1,8 @@ +using Domain.Entities; + +namespace Application.Features.Payment.Interfaces; + +public interface PaymentGatewayResolverContract +{ + PaymentGatewayProviderContract Resolve(PaymentGateway gateway); +} \ No newline at end of file diff --git a/Application/Features/Payment/Interfaces/PaymentRepositoryContract.cs b/Application/Features/Payment/Interfaces/PaymentRepositoryContract.cs index e017343..660596e 100644 --- a/Application/Features/Payment/Interfaces/PaymentRepositoryContract.cs +++ b/Application/Features/Payment/Interfaces/PaymentRepositoryContract.cs @@ -7,5 +7,6 @@ public interface PaymentRepositoryContract Task CreatePaymentAsync(Domain.Entities.Payment payment); Task SaveAsync(); Task GetPaymentByResNumAsync(string resNum); + Task GetPaymentByAuthorityAsync(string authority); Task IsExistByRefNum(string refNum); } \ No newline at end of file diff --git a/Application/Features/Payment/Interfaces/PaymentServiceContract.cs b/Application/Features/Payment/Interfaces/PaymentServiceContract.cs index d1dace7..776b744 100644 --- a/Application/Features/Payment/Interfaces/PaymentServiceContract.cs +++ b/Application/Features/Payment/Interfaces/PaymentServiceContract.cs @@ -1,11 +1,13 @@ using Application.Features.Payment.DTOs; +using Application.Features.Payment.DTOs.ZarinPal; +using Domain.Entities; +using Microsoft.AspNetCore.Http; namespace Application.Features.Payment.Interfaces; public interface PaymentServiceContract { - Task GenerateResNum(); - Task CreatePaymentAsync(CreatePaymentDto dto); - Task ProccessCallBack(SandBoxCallBackDto dto); + Task CreatePaymentAsync(CreatePaymentDto dto); + Task HandleCallBackAsync(PaymentGateway gateway,SandBoxCallBackDto dto); Task VerifyTransaction(string RefNum); } \ No newline at end of file diff --git a/Application/Features/Payment/Services/PaymentService.cs b/Application/Features/Payment/Services/PaymentService.cs index 44941af..258a0c2 100644 --- a/Application/Features/Payment/Services/PaymentService.cs +++ b/Application/Features/Payment/Services/PaymentService.cs @@ -1,7 +1,10 @@ using System.Net.Http.Json; using System.Security.Cryptography; using Application.Features.Payment.DTOs; +using Application.Features.Payment.DTOs.ZarinPal; using Application.Features.Payment.Interfaces; +using Domain.Entities; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; namespace Application.Features.Payment.Services; @@ -9,59 +12,64 @@ namespace Application.Features.Payment.Services; public class PaymentService : PaymentServiceContract { private readonly PaymentRepositoryContract _paymentRepository; + private readonly PaymentGatewayResolverContract _gatewayResolver; private readonly IConfiguration _configuration; private readonly HttpClient _httpClient; - public PaymentService(PaymentRepositoryContract paymentRepository, IConfiguration configuration, HttpClient httpClient) + + public PaymentService(PaymentRepositoryContract paymentRepository, IConfiguration configuration, + HttpClient httpClient, PaymentGatewayResolverContract gatewayResolver) { _paymentRepository = paymentRepository; _configuration = configuration; _httpClient = httpClient; + _gatewayResolver = gatewayResolver; } - public async Task GenerateResNum() + // public async Task CreatePaymentAsync(CreatePaymentDto dto) + // { + // var payment = new Domain.Entities.Payment(dto.TenantId, dto.AppointmentId, dto.Amount, dto.Gateway); + // + // var provider = _gatewayResolver.Resolve(dto.Gateway); + // + // var requestResult=await provider.RequestPaymentAsync(payment,dto); + // + // if (!requestResult.IsSuccess) + // { + // payment.MarkAsFailed(); + // await _paymentRepository.SaveAsync(); + // + // throw new InvalidOperationException(requestResult.ErrorMessage); + // } + // await _paymentRepository.CreatePaymentAsync(payment); + // await _paymentRepository.SaveAsync(); + // return requestResult.PaymentUrl; + public async Task CreatePaymentAsync(CreatePaymentDto dto) { - string timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss"); - string shortGuid = Guid.NewGuid().ToString("N").Substring(0, 8); - return $"{timestamp}{shortGuid}"; - } + var payment = new Domain.Entities.Payment(dto.TenantId, dto.AppointmentId, dto.Amount, dto.Gateway); - public async Task CreatePaymentAsync(CreatePaymentDto dto) - { - var resNum = await GenerateResNum(); - var payment = new Domain.Entities.Payment(dto.TenantId, dto.AppointmentId, dto.Amount, resNum); - await _paymentRepository.CreatePaymentAsync(payment); - await _paymentRepository.SaveAsync(); - return resNum; - } + var provider = _gatewayResolver.Resolve(dto.Gateway); - public async Task ProccessCallBack(SandBoxCallBackDto dto) - { - // if (dto.State != "OK") - // throw new Exception("پرداخت ناموفق"); - if (dto.ResNum == null) - throw new Exception("شماره رزرو نامعتبر است ."); - var payment = await _paymentRepository.GetPaymentByResNumAsync(dto.ResNum); - if (payment == null) - throw new Exception("تراکنش نامعتبر"); - payment.Edit(dto.Status, dto.State, dto.RRN, dto.RefNum, dto.ResNum, dto.TraceNo, dto.Amount, dto.Wage); + var requestResult = await provider.RequestPaymentAsync(payment, dto); - if (dto.State != "OK" || dto.Status != 2) - { - payment.MarkAsFailed(); - await _paymentRepository.SaveAsync(); - return false; - } - - var verifyTransaction= await VerifyTransaction(dto.RefNum); - if (!verifyTransaction) + if (!requestResult.IsSuccess) { payment.MarkAsFailed(); await _paymentRepository.SaveAsync(); - return false; + + throw new InvalidOperationException(requestResult.ErrorMessage); } - payment.MarkAsSuccess(); + payment.Authority=requestResult.GatewayToken; + await _paymentRepository.CreatePaymentAsync(payment); await _paymentRepository.SaveAsync(); - return true; + return requestResult.PaymentUrl; + } + + public async Task HandleCallBackAsync(PaymentGateway gateway, SandBoxCallBackDto dto) + { + var provider = _gatewayResolver.Resolve(gateway); + var result= await provider.HandleCallBackAsync(gateway, dto); + + return result; } public async Task VerifyTransaction(string RefNum) @@ -69,16 +77,17 @@ public async Task VerifyTransaction(string RefNum) var request = new { refNum = RefNum, - TerminalNumber =_configuration["Payment:TerminalId"] + TerminalNumber = _configuration["Payment:TerminalId"] }; - var verifyUrl=_configuration["Payment:VerifyTransactionUrl"]; - var response =await _httpClient.PostAsJsonAsync(verifyUrl,request); + var verifyUrl = _configuration["Payment:VerifyTransactionUrl"]; + var response = await _httpClient.PostAsJsonAsync(verifyUrl, request); if (!response.IsSuccessStatusCode) return false; - + var result = await response.Content.ReadAsStringAsync(); Console.WriteLine(result); return result is not null; } + } \ No newline at end of file diff --git a/Domain/Entities/Payment.cs b/Domain/Entities/Payment.cs index 74a1034..b887ac5 100644 --- a/Domain/Entities/Payment.cs +++ b/Domain/Entities/Payment.cs @@ -13,7 +13,7 @@ public class Payment public long Amount { get; set; } public string? Wage { get; set; } public string ResNum { get; set; } - + public string Description { get; set; } public string? RefNum { get; set; } public string? TraceNo { get; set; } @@ -21,27 +21,29 @@ public class Payment public string? RRN { get; set; } public string? CardNumber { get; set; } + public string? Authority { get; set; } public PaymentStatus PaymentStatus{ get; set; } - public int? PaymentGatewayStatus { get; set; } + public string? PaymentGatewayStatus { get; set; } + public PaymentGateway Gateway { get; set; } public string? SecurePan { get; set; } public DateTime CreatedAt { get; set; } public DateTime? PaidAt { get; set; } - public Payment(Guid tenantId, Guid appointmentId, long amount, string resNum) + public Payment(Guid tenantId, Guid appointmentId, long amount, PaymentGateway gateway) { Id = Guid.NewGuid(); TenantId = tenantId; AppointmentId = appointmentId; Amount = amount; - ResNum = resNum; + Gateway=gateway; PaymentStatus = PaymentStatus.pending; CreatedAt = DateTime.UtcNow; } - public void Edit(int? paymentGatewayStatus, string? state, string? _RRN, string? refNum, string? resNum, string? traceNo, long amount, string? wage) + public void Edit(string? paymentGatewayStatus, string? state, string? _RRN, string? refNum, string? resNum, string? traceNo, string? wage) { PaymentGatewayStatus= paymentGatewayStatus; State=state; @@ -49,10 +51,15 @@ public void Edit(int? paymentGatewayStatus, string? state, string? _RRN, string? RefNum=refNum; ResNum=resNum; TraceNo=traceNo; - Amount = amount; Wage=wage; } - + public void Edit(string? paymentGatewayStatus, int? refNum, string? securePan, int? fee) + { + PaymentGatewayStatus= paymentGatewayStatus; + RefNum= refNum.ToString(); + SecurePan=securePan; + Wage=fee.ToString(); + } void SetPaidAt() { PaidAt = DateTime.UtcNow; @@ -63,7 +70,14 @@ public void MarkAsFailed() } public void MarkAsSuccess() { - PaymentStatus = PaymentStatus.Failed; + PaymentStatus = PaymentStatus.Success; + } + + public void GenerateOrderNumber() + { + string timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss"); + string shortGuid = Guid.NewGuid().ToString("N").Substring(0, 8); + ResNum= $"{timestamp}{shortGuid}"; } } diff --git a/Domain/Entities/PaymentGateway.cs b/Domain/Entities/PaymentGateway.cs new file mode 100644 index 0000000..d775e57 --- /dev/null +++ b/Domain/Entities/PaymentGateway.cs @@ -0,0 +1,7 @@ +namespace Domain.Entities; + +public enum PaymentGateway +{ + Saman, + ZarinPal +} \ No newline at end of file diff --git a/Infrastructure/Infrastructure.csproj b/Infrastructure/Infrastructure.csproj index b0a2d27..61868b6 100644 --- a/Infrastructure/Infrastructure.csproj +++ b/Infrastructure/Infrastructure.csproj @@ -25,4 +25,8 @@ + + + + diff --git a/Infrastructure/InfrastructureServices.cs b/Infrastructure/InfrastructureServices.cs index 64a9d90..9f9e380 100644 --- a/Infrastructure/InfrastructureServices.cs +++ b/Infrastructure/InfrastructureServices.cs @@ -13,6 +13,7 @@ using Infrastructure.Security.Context; using Infrastructure.Security.Hashing; using Infrastructure.Security.Jwt; +using Infrastructure.Services.Implement; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -38,6 +39,9 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); return services; } diff --git a/Infrastructure/Migrations/20260627153204_GatewayAddedToPayment.Designer.cs b/Infrastructure/Migrations/20260627153204_GatewayAddedToPayment.Designer.cs new file mode 100644 index 0000000..719ce2f --- /dev/null +++ b/Infrastructure/Migrations/20260627153204_GatewayAddedToPayment.Designer.cs @@ -0,0 +1,404 @@ +// +using System; +using Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260627153204_GatewayAddedToPayment")] + partial class GatewayAddedToPayment + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Domain.Entities.Appointment", b => + { + b.Property("AppointmentId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("EndTime") + .HasColumnType("datetime2"); + + b.Property("StartTime") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("AppointmentId"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.ToTable("Appointments", t => + { + t.HasCheckConstraint("CK_Status_Valid_Values", "[Status] IN (0, 1,2)"); + }); + }); + + modelBuilder.Entity("Domain.Entities.AppointmentServiceLink", b => + { + b.Property("AppointmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("ServiceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("AppointmentId", "ServiceId"); + + b.HasIndex("ServiceId"); + + b.ToTable("AppointmentServiceLinks", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Payment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Amount") + .HasColumnType("bigint"); + + b.Property("AppointmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("CardNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Gateway") + .HasColumnType("int"); + + b.Property("PaidAt") + .HasColumnType("datetime2"); + + b.Property("PaymentGatewayStatus") + .HasColumnType("int"); + + b.Property("PaymentStatus") + .HasColumnType("int"); + + b.Property("RRN") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RefNum") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ResNum") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("SecurePan") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("TraceNo") + .HasColumnType("nvarchar(max)"); + + b.Property("Wage") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AppointmentId"); + + b.HasIndex("ResNum") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("Payments", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsRevoked") + .HasColumnType("bit"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("Domain.Entities.Service", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("DurationMinutes") + .HasColumnType("int"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("Service", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("DateJoined") + .HasColumnType("datetime2"); + + b.Property("ExpireSubscriptionDate") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(true) + .HasColumnType("nvarchar(200)"); + + b.Property("SubscriptionStatus") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Tenants", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Password") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("PasswordResetAttemptsCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("PasswordResetCodeExpireAt") + .HasColumnType("datetime2"); + + b.Property("PasswordResetCodeHash") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("PhoneNumber") + .IsRequired() + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("Users", null, t => + { + t.HasCheckConstraint("CK_Role_Valid_Values", "[Role] IN (0,1,2,3)"); + }); + }); + + modelBuilder.Entity("Domain.Entities.Appointment", b => + { + b.HasOne("Domain.Entities.Tenant", "Tenant") + .WithMany("Appointments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Domain.Entities.User", "User") + .WithMany("Appointments") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.AppointmentServiceLink", b => + { + b.HasOne("Domain.Entities.Appointment", "Appointment") + .WithMany("AppointmentServices") + .HasForeignKey("AppointmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Domain.Entities.Service", "Service") + .WithMany("AppointmentServices") + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Appointment"); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("Domain.Entities.Payment", b => + { + b.HasOne("Domain.Entities.Appointment", "apppointment") + .WithMany("Payments") + .HasForeignKey("AppointmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("apppointment"); + }); + + modelBuilder.Entity("Domain.Entities.RefreshToken", b => + { + b.HasOne("Domain.Entities.User", "User") + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.Service", b => + { + b.HasOne("Domain.Entities.Tenant", "Tenant") + .WithMany("Services") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.HasOne("Domain.Entities.Tenant", "Tenant") + .WithMany("Users") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Domain.Entities.Appointment", b => + { + b.Navigation("AppointmentServices"); + + b.Navigation("Payments"); + }); + + modelBuilder.Entity("Domain.Entities.Service", b => + { + b.Navigation("AppointmentServices"); + }); + + modelBuilder.Entity("Domain.Entities.Tenant", b => + { + b.Navigation("Appointments"); + + b.Navigation("Services"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Navigation("Appointments"); + + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Infrastructure/Migrations/20260627153204_GatewayAddedToPayment.cs b/Infrastructure/Migrations/20260627153204_GatewayAddedToPayment.cs new file mode 100644 index 0000000..7932fe8 --- /dev/null +++ b/Infrastructure/Migrations/20260627153204_GatewayAddedToPayment.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Migrations +{ + /// + public partial class GatewayAddedToPayment : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Gateway", + table: "Payments", + type: "int", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Gateway", + table: "Payments"); + } + } +} diff --git a/Infrastructure/Migrations/20260628154520_AuthorityAddedToPayments.Designer.cs b/Infrastructure/Migrations/20260628154520_AuthorityAddedToPayments.Designer.cs new file mode 100644 index 0000000..a6a4eec --- /dev/null +++ b/Infrastructure/Migrations/20260628154520_AuthorityAddedToPayments.Designer.cs @@ -0,0 +1,407 @@ +// +using System; +using Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260628154520_AuthorityAddedToPayments")] + partial class AuthorityAddedToPayments + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Domain.Entities.Appointment", b => + { + b.Property("AppointmentId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("EndTime") + .HasColumnType("datetime2"); + + b.Property("StartTime") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("AppointmentId"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.ToTable("Appointments", t => + { + t.HasCheckConstraint("CK_Status_Valid_Values", "[Status] IN (0, 1,2)"); + }); + }); + + modelBuilder.Entity("Domain.Entities.AppointmentServiceLink", b => + { + b.Property("AppointmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("ServiceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("AppointmentId", "ServiceId"); + + b.HasIndex("ServiceId"); + + b.ToTable("AppointmentServiceLinks", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Payment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Amount") + .HasColumnType("bigint"); + + b.Property("AppointmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Authority") + .HasColumnType("nvarchar(max)"); + + b.Property("CardNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Gateway") + .HasColumnType("int"); + + b.Property("PaidAt") + .HasColumnType("datetime2"); + + b.Property("PaymentGatewayStatus") + .HasColumnType("nvarchar(max)"); + + b.Property("PaymentStatus") + .HasColumnType("int"); + + b.Property("RRN") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RefNum") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ResNum") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("SecurePan") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("TraceNo") + .HasColumnType("nvarchar(max)"); + + b.Property("Wage") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AppointmentId"); + + b.HasIndex("ResNum") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("Payments", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsRevoked") + .HasColumnType("bit"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("Domain.Entities.Service", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("DurationMinutes") + .HasColumnType("int"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("Service", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("DateJoined") + .HasColumnType("datetime2"); + + b.Property("ExpireSubscriptionDate") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(true) + .HasColumnType("nvarchar(200)"); + + b.Property("SubscriptionStatus") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Tenants", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Password") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("PasswordResetAttemptsCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("PasswordResetCodeExpireAt") + .HasColumnType("datetime2"); + + b.Property("PasswordResetCodeHash") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("PhoneNumber") + .IsRequired() + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("Users", null, t => + { + t.HasCheckConstraint("CK_Role_Valid_Values", "[Role] IN (0,1,2,3)"); + }); + }); + + modelBuilder.Entity("Domain.Entities.Appointment", b => + { + b.HasOne("Domain.Entities.Tenant", "Tenant") + .WithMany("Appointments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Domain.Entities.User", "User") + .WithMany("Appointments") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.AppointmentServiceLink", b => + { + b.HasOne("Domain.Entities.Appointment", "Appointment") + .WithMany("AppointmentServices") + .HasForeignKey("AppointmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Domain.Entities.Service", "Service") + .WithMany("AppointmentServices") + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Appointment"); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("Domain.Entities.Payment", b => + { + b.HasOne("Domain.Entities.Appointment", "apppointment") + .WithMany("Payments") + .HasForeignKey("AppointmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("apppointment"); + }); + + modelBuilder.Entity("Domain.Entities.RefreshToken", b => + { + b.HasOne("Domain.Entities.User", "User") + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.Service", b => + { + b.HasOne("Domain.Entities.Tenant", "Tenant") + .WithMany("Services") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.HasOne("Domain.Entities.Tenant", "Tenant") + .WithMany("Users") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Domain.Entities.Appointment", b => + { + b.Navigation("AppointmentServices"); + + b.Navigation("Payments"); + }); + + modelBuilder.Entity("Domain.Entities.Service", b => + { + b.Navigation("AppointmentServices"); + }); + + modelBuilder.Entity("Domain.Entities.Tenant", b => + { + b.Navigation("Appointments"); + + b.Navigation("Services"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Navigation("Appointments"); + + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Infrastructure/Migrations/20260628154520_AuthorityAddedToPayments.cs b/Infrastructure/Migrations/20260628154520_AuthorityAddedToPayments.cs new file mode 100644 index 0000000..55682c0 --- /dev/null +++ b/Infrastructure/Migrations/20260628154520_AuthorityAddedToPayments.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Migrations +{ + /// + public partial class AuthorityAddedToPayments : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "PaymentGatewayStatus", + table: "Payments", + type: "nvarchar(max)", + nullable: true, + oldClrType: typeof(int), + oldType: "int", + oldNullable: true); + + migrationBuilder.AddColumn( + name: "Authority", + table: "Payments", + type: "nvarchar(max)", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Authority", + table: "Payments"); + + migrationBuilder.AlterColumn( + name: "PaymentGatewayStatus", + table: "Payments", + type: "int", + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(max)", + oldNullable: true); + } + } +} diff --git a/Infrastructure/Migrations/20260628195138_DescriptionAddedToPayments.Designer.cs b/Infrastructure/Migrations/20260628195138_DescriptionAddedToPayments.Designer.cs new file mode 100644 index 0000000..8a92d88 --- /dev/null +++ b/Infrastructure/Migrations/20260628195138_DescriptionAddedToPayments.Designer.cs @@ -0,0 +1,411 @@ +// +using System; +using Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260628195138_DescriptionAddedToPayments")] + partial class DescriptionAddedToPayments + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Domain.Entities.Appointment", b => + { + b.Property("AppointmentId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("EndTime") + .HasColumnType("datetime2"); + + b.Property("StartTime") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("AppointmentId"); + + b.HasIndex("TenantId"); + + b.HasIndex("UserId"); + + b.ToTable("Appointments", t => + { + t.HasCheckConstraint("CK_Status_Valid_Values", "[Status] IN (0, 1,2)"); + }); + }); + + modelBuilder.Entity("Domain.Entities.AppointmentServiceLink", b => + { + b.Property("AppointmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("ServiceId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("AppointmentId", "ServiceId"); + + b.HasIndex("ServiceId"); + + b.ToTable("AppointmentServiceLinks", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Payment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Amount") + .HasColumnType("bigint"); + + b.Property("AppointmentId") + .HasColumnType("uniqueidentifier"); + + b.Property("Authority") + .HasColumnType("nvarchar(max)"); + + b.Property("CardNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Gateway") + .HasColumnType("int"); + + b.Property("PaidAt") + .HasColumnType("datetime2"); + + b.Property("PaymentGatewayStatus") + .HasColumnType("nvarchar(max)"); + + b.Property("PaymentStatus") + .HasColumnType("int"); + + b.Property("RRN") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RefNum") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ResNum") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("SecurePan") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("TraceNo") + .HasColumnType("nvarchar(max)"); + + b.Property("Wage") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AppointmentId"); + + b.HasIndex("ResNum") + .IsUnique(); + + b.HasIndex("TenantId"); + + b.ToTable("Payments", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsRevoked") + .HasColumnType("bit"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("Domain.Entities.Service", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("DurationMinutes") + .HasColumnType("int"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("Service", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("DateJoined") + .HasColumnType("datetime2"); + + b.Property("ExpireSubscriptionDate") + .HasColumnType("datetime2"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .IsUnicode(true) + .HasColumnType("nvarchar(200)"); + + b.Property("SubscriptionStatus") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Tenants", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Password") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("PasswordResetAttemptsCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("PasswordResetCodeExpireAt") + .HasColumnType("datetime2"); + + b.Property("PasswordResetCodeHash") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("PhoneNumber") + .IsRequired() + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("Users", null, t => + { + t.HasCheckConstraint("CK_Role_Valid_Values", "[Role] IN (0,1,2,3)"); + }); + }); + + modelBuilder.Entity("Domain.Entities.Appointment", b => + { + b.HasOne("Domain.Entities.Tenant", "Tenant") + .WithMany("Appointments") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Domain.Entities.User", "User") + .WithMany("Appointments") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.AppointmentServiceLink", b => + { + b.HasOne("Domain.Entities.Appointment", "Appointment") + .WithMany("AppointmentServices") + .HasForeignKey("AppointmentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Domain.Entities.Service", "Service") + .WithMany("AppointmentServices") + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Appointment"); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("Domain.Entities.Payment", b => + { + b.HasOne("Domain.Entities.Appointment", "apppointment") + .WithMany("Payments") + .HasForeignKey("AppointmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("apppointment"); + }); + + modelBuilder.Entity("Domain.Entities.RefreshToken", b => + { + b.HasOne("Domain.Entities.User", "User") + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.Service", b => + { + b.HasOne("Domain.Entities.Tenant", "Tenant") + .WithMany("Services") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.HasOne("Domain.Entities.Tenant", "Tenant") + .WithMany("Users") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("Domain.Entities.Appointment", b => + { + b.Navigation("AppointmentServices"); + + b.Navigation("Payments"); + }); + + modelBuilder.Entity("Domain.Entities.Service", b => + { + b.Navigation("AppointmentServices"); + }); + + modelBuilder.Entity("Domain.Entities.Tenant", b => + { + b.Navigation("Appointments"); + + b.Navigation("Services"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("Domain.Entities.User", b => + { + b.Navigation("Appointments"); + + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Infrastructure/Migrations/20260628195138_DescriptionAddedToPayments.cs b/Infrastructure/Migrations/20260628195138_DescriptionAddedToPayments.cs new file mode 100644 index 0000000..8fb49c0 --- /dev/null +++ b/Infrastructure/Migrations/20260628195138_DescriptionAddedToPayments.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Migrations +{ + /// + public partial class DescriptionAddedToPayments : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Description", + table: "Payments", + type: "nvarchar(max)", + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Description", + table: "Payments"); + } + } +} diff --git a/Infrastructure/Persistence/Configurations/PaymentConfiguration.cs b/Infrastructure/Persistence/Configurations/PaymentConfiguration.cs index d70c48a..af90e56 100644 --- a/Infrastructure/Persistence/Configurations/PaymentConfiguration.cs +++ b/Infrastructure/Persistence/Configurations/PaymentConfiguration.cs @@ -20,6 +20,10 @@ public void Configure(EntityTypeBuilder builder) .Property(p => p.Amount) .IsRequired(); + builder + .Property(p => p.Description) + .IsRequired(); + builder.Property(p=>p.ResNum) .HasMaxLength(100) .IsRequired(); @@ -65,5 +69,11 @@ public void Configure(EntityTypeBuilder builder) builder .HasIndex(p => p.TenantId); + + builder + .Property(p => p.Gateway); + + builder + .Property(p => p.Authority); } } \ No newline at end of file diff --git a/Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index 76747ac..81bd5c9 100644 --- a/Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -87,17 +87,27 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("AppointmentId") .HasColumnType("uniqueidentifier"); + b.Property("Authority") + .HasColumnType("nvarchar(max)"); + b.Property("CardNumber") .HasColumnType("nvarchar(max)"); b.Property("CreatedAt") .HasColumnType("datetime2"); + b.Property("Description") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Gateway") + .HasColumnType("int"); + b.Property("PaidAt") .HasColumnType("datetime2"); - b.Property("PaymentGatewayStatus") - .HasColumnType("int"); + b.Property("PaymentGatewayStatus") + .HasColumnType("nvarchar(max)"); b.Property("PaymentStatus") .HasColumnType("int"); diff --git a/Infrastructure/Persistence/Repositories/PaymentRepository.cs b/Infrastructure/Persistence/Repositories/PaymentRepository.cs index 3cff37f..48ba08a 100644 --- a/Infrastructure/Persistence/Repositories/PaymentRepository.cs +++ b/Infrastructure/Persistence/Repositories/PaymentRepository.cs @@ -33,6 +33,14 @@ public async Task SaveAsync() .FirstOrDefaultAsync(); } + public async Task GetPaymentByAuthorityAsync(string authority) + { + return await _dbContext + .Payments + .Where(p => p.Authority == authority) + .FirstOrDefaultAsync(); + } + public Task IsExistByRefNum(string refNum) { throw new NotImplementedException(); diff --git a/Infrastructure/Services/Implement/PaymentGatewayResolver.cs b/Infrastructure/Services/Implement/PaymentGatewayResolver.cs new file mode 100644 index 0000000..bec1cc6 --- /dev/null +++ b/Infrastructure/Services/Implement/PaymentGatewayResolver.cs @@ -0,0 +1,24 @@ +using Application.Features.Payment.Interfaces; +using Domain.Entities; + +namespace Infrastructure.Services.Implement; + +public class PaymentGatewayResolver:PaymentGatewayResolverContract +{ + private readonly IEnumerable _providers; + + public PaymentGatewayResolver(IEnumerable providers) + { + _providers = providers; + } + + public PaymentGatewayProviderContract Resolve(PaymentGateway gateway) + { + var provider = _providers.FirstOrDefault(x => x.Gateway == gateway); + + if (provider is null) + throw new InvalidOperationException($"no provider found for gateway : {gateway}"); + + return provider; + } +} \ No newline at end of file diff --git a/Infrastructure/Services/Implement/SamanPaymentGatewayProvider.cs b/Infrastructure/Services/Implement/SamanPaymentGatewayProvider.cs new file mode 100644 index 0000000..491f565 --- /dev/null +++ b/Infrastructure/Services/Implement/SamanPaymentGatewayProvider.cs @@ -0,0 +1,93 @@ +using System.Net.Http.Json; +using System.Text.Json; +using Application.Features.Payment.DTOs; +using Application.Features.Payment.DTOs.Saman; +using Application.Features.Payment.Interfaces; +using Domain.Entities; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; + +namespace Infrastructure.Services.Implement; + +public class SamanPaymentGatewayProvider : PaymentGatewayProviderContract +{ + private readonly HttpClient _httpClient; + private readonly IConfiguration _configuration; + private readonly PaymentRepositoryContract _paymentRepositoryContract; + + public SamanPaymentGatewayProvider(HttpClient httpClient, IConfiguration configuration, PaymentRepositoryContract paymentRepositoryContract) + { + _httpClient = httpClient; + _configuration = configuration; + _paymentRepositoryContract = paymentRepositoryContract; + } + + public PaymentGateway Gateway => PaymentGateway.Saman; + + public async Task RequestPaymentAsync(Payment payment, CreatePaymentDto dto) + { + var paymentPageUrl = "https://sandbox.banktest.ir/saman/sep.shaparak.ir/OnlinePG/SendToken"; + payment.GenerateOrderNumber(); + var dtoRequest = new SamanTokenRequestDto() + { + Action = "token", + Amount = dto.Amount, + ResNum = payment.ResNum, + }; + var request = new + { + dtoRequest.Action, + TerminalId = _configuration["Payment:TerminalId"]!, + dtoRequest.Amount, + dtoRequest.ResNum, + RedirectUrl = _configuration["Payment:SamanRedirectUrl"], + dtoRequest.CellNumber + }; + using var response = await _httpClient.PostAsJsonAsync( + "https://sandbox.banktest.ir/saman/sep.shaparak.ir/OnlinePG/OnlinePG", + request); + + var raw = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + return PaymentGatewayRequestResult.Failed( + ((int)response.StatusCode).ToString(), + "خطا در ارتباط با درگاه سامان"); + } + + var result = JsonSerializer.Deserialize(raw); + + if (result is null || string.IsNullOrWhiteSpace(result.Token)) + { + return PaymentGatewayRequestResult.Failed( + "INVALID_RESPONSE", + "پاسخ نامعتبر از درگاه سامان"); + } + + var paymentUrl = $"{paymentPageUrl}?token={result.Token}"; + + return PaymentGatewayRequestResult.Success(result.Token, paymentUrl); + } + + public async Task HandleCallBackAsync(PaymentGateway gateway, SandBoxCallBackDto dto) + { + var payment=await _paymentRepositoryContract.GetPaymentByAuthorityAsync(dto.Authority); + var request = new + { + RefNum = payment.RefNum, + TerminalNumber = _configuration["Payment:TerminalId"] + }; + var verifyUrl = _configuration["Payment:VerifyTransactionUrl"]; + + var response = await _httpClient.PostAsJsonAsync(verifyUrl, request); + + var result=await response.Content.ReadFromJsonAsync(); + + if (!response.IsSuccessStatusCode) + return "پرداخت ناموفق"; + + return result.ResultDescription; + } + +} \ No newline at end of file diff --git a/Infrastructure/Services/Implement/ZarinPalPaymentGatewayProvider.cs b/Infrastructure/Services/Implement/ZarinPalPaymentGatewayProvider.cs new file mode 100644 index 0000000..35ff3dc --- /dev/null +++ b/Infrastructure/Services/Implement/ZarinPalPaymentGatewayProvider.cs @@ -0,0 +1,99 @@ +using System.Net.Http.Json; +using System.Text.Json; +using Application.Features.Payment.DTOs; +using Application.Features.Payment.DTOs.ZarinPal; +using Application.Features.Payment.Interfaces; +using Domain.Entities; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; + +namespace Infrastructure.Services.Implement; + +public class ZarinPalPaymentGatewayProvider : PaymentGatewayProviderContract +{ + private readonly IConfiguration _config; + private readonly HttpClient _httpClient; + private readonly PaymentRepositoryContract _repositoryContract; + + public ZarinPalPaymentGatewayProvider(IConfiguration config, HttpClient httpClient, PaymentRepositoryContract repositoryContract) + { + _config = config; + _httpClient = httpClient; + _repositoryContract = repositoryContract; + } + + public PaymentGateway Gateway => PaymentGateway.ZarinPal; + + public async Task RequestPaymentAsync(Payment payment, CreatePaymentDto dto) + { + payment.GenerateOrderNumber(); + var requestDto = new CreateZarinPalPaymentDto + { + Amount = dto.Amount, + Description = dto.Description, + ReferrerId = dto.ReferrerId, + ZarinPalMataData = new ZarinPalMataData + { + Mobile = dto.Mobile, + Email = dto.Email, + OrderId = payment.ResNum + } + }; + var request = new + { + merchant_id = _config["Payment:MerchantIdZarinPal"], + amount = requestDto.Amount, + description = requestDto.Description, + callback_url = _config["Payment:ZarinPalCallBackUrl"], + matadata = requestDto.ZarinPalMataData + }; + + using var response = + await _httpClient.PostAsJsonAsync("https://sandbox.zarinpal.com/pg/v4/payment/request.json", request); + + var raw = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + return PaymentGatewayRequestResult.Failed( + ((int)response.StatusCode).ToString(), + "خطا در ارتباط با درگاه زرین پال"); + } + + var result = JsonSerializer.Deserialize(raw); + if (result is null || string.IsNullOrWhiteSpace(result.Data.Authority)) + { + return PaymentGatewayRequestResult.Failed( + "INVALID_RESPONSE", + "پاسخ نامعتبر از درگاه زرین پال"); + } + + var paymentUrl = "https://sandbox.zarinpal.com/pg/StartPay/" + result?.Data.Authority; + return PaymentGatewayRequestResult.Success(result.Data.Authority, paymentUrl); + } + + public async Task HandleCallBackAsync(PaymentGateway gateway, SandBoxCallBackDto dto) + { + if (dto.Status != "OK") + { + return "پرداخت ناموفق"; + } + var payment=await _repositoryContract.GetPaymentByAuthorityAsync(dto.Authority); + var verifyRequest = new + { + merchant_id = _config["Payment:MerchantIdZarinPal"], + amount = payment.Amount, + authority = dto.Authority + }; + var verifyResponse = + await _httpClient.PostAsJsonAsync("https://sandbox.zarinpal.com/pg/v4/payment/verify.json", verifyRequest); + + var result = await verifyResponse.Content.ReadFromJsonAsync(); + + if (result.Data.Code != 100 && result.Data.Code != 101) + return "پرداخت ناموفق ."; + payment.Edit(dto.Status, result.Data.RefId, result.Data.CardPan, result.Data.Fee); + await _repositoryContract.SaveAsync(); + return "پرداخت با موفقیت انجام شد شماره پیگیری " + result.Data.RefId; + } +} \ No newline at end of file diff --git a/api/Controllers/PaymentController.cs b/api/Controllers/PaymentController.cs index 212bb90..ea04e9c 100644 --- a/api/Controllers/PaymentController.cs +++ b/api/Controllers/PaymentController.cs @@ -1,7 +1,10 @@ using System.Text; using System.Text.Json; using Application.Features.Payment.DTOs; +using Application.Features.Payment.DTOs.Saman; +using Application.Features.Payment.DTOs.ZarinPal; using Application.Features.Payment.Interfaces; +using Domain.Entities; using Microsoft.AspNetCore.Mvc; namespace api.Controllers; @@ -13,6 +16,7 @@ public class PaymentController : ControllerBase private readonly HttpClient _httpClient; private readonly IConfiguration _config; private readonly PaymentServiceContract _paymentServiceContract; + public PaymentController(HttpClient httpClient, IConfiguration config, PaymentServiceContract paymentServiceContract) { @@ -20,114 +24,53 @@ public PaymentController(HttpClient httpClient, IConfiguration config, _config = config; _paymentServiceContract = paymentServiceContract; } - [HttpPost] - public async Task RequestToken([FromBody] CreatePaymentDto dto) + public async Task GetPaymentUrl([FromBody] CreatePaymentDto dto) { - var resNum = await _paymentServiceContract.CreatePaymentAsync(dto); - var requestBody = new - { - action = "token", - TerminalId = _config["Payment:TerminalId"], - Amount = dto.Amount, - ResNum = resNum, - RedirectUrl = "http://salon1.localhost:8596/api/Payment/CallBack" - }; - - var content = new StringContent( - JsonSerializer.Serialize(requestBody), - Encoding.UTF8, - "application/json"); - - var response = - await _httpClient.PostAsync("https://sandbox.banktest.ir/saman/sep.shaparak.ir/OnlinePG/OnlinePG", content); - - var result = await response.Content.ReadFromJsonAsync(); - - if (result is null || result.status != 1) - return BadRequest(result); - - return Ok(new CreatePaymentResponse - { - PayUrl = $"https://sandbox.banktest.ir/saman/sep.shaparak.ir/OnlinePG/SendToken?token={result.token}" - }); + var paymentUrl = await _paymentServiceContract.CreatePaymentAsync(dto); + return Ok(paymentUrl); } - - [HttpPost] - public async Task RequestTokenZarinPal([FromBody] CreateZarinPalPaymentDto dto) + private Dictionary GetAllValues() { - var request = new + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var q in Request.Query) + dict[q.Key] = q.Value.FirstOrDefault(); + + if (Request.HasFormContentType) { - merchant_id = _config["Payment:MerchantIdZarinPal"], - amount = dto.Amount, - description = dto.Description, - callback_url = _config["Payment:ZarinPalCallBackUrl"] - }; - var response = await _httpClient.PostAsJsonAsync("https://sandbox.zarinpal.com/pg/v4/payment/request.json",request); + foreach (var f in Request.Form) + dict[f.Key] = f.Value.FirstOrDefault(); + } - var result = await response.Content.ReadFromJsonAsync(); - var paymentUrl = "https://sandbox.zarinpal.com/pg/StartPay/"+result?.Data.Authority; - - return Ok(paymentUrl); + return dict; } - [HttpPost] - public async Task CallBack() + [HttpPost("{gateway}")] + [HttpGet("{gateway}")] + public async Task CallBack([FromRoute] PaymentGateway gateway) { - var response=Request.Form.ToDictionary(x=>x.Key, x=>x.Value); + var values = GetAllValues(); + + values.TryGetValue("Amount", out var amountStr); + var dto = new SandBoxCallBackDto { - MID = response.GetValueOrDefault("MID"), - Status = int.Parse(response.GetValueOrDefault("Status")), - State = response.GetValueOrDefault("State"), - RRN = response.GetValueOrDefault("RRN"), - RefNum = response.GetValueOrDefault("RefNum"), - ResNum = response.GetValueOrDefault("ResNum"), - TraceNo = response.GetValueOrDefault("TraceNo"), - Amount = long.Parse(response.GetValueOrDefault("Amount")), - Wage = response.GetValueOrDefault("Wage"), - CID = response.GetValueOrDefault("CID"), - SecurePan = response.GetValueOrDefault("SecurePan"), - Token = response.GetValueOrDefault("Token"), + Authority = values.GetValueOrDefault("Authority"), + Status = values.GetValueOrDefault("Status"), + State = values.GetValueOrDefault("State"), + Amount = long.TryParse(amountStr, out var amount) ? amount : null, + RRN = values.GetValueOrDefault("RRN"), + RefNum = values.GetValueOrDefault("RefNum"), + ResNum = values.GetValueOrDefault("ResNum"), + TraceNo = values.GetValueOrDefault("TraceNo"), + Wage = values.GetValueOrDefault("Wage"), + SecurePan = values.GetValueOrDefault("SecurePan"), + CID = values.GetValueOrDefault("CID"), + Token = values.GetValueOrDefault("Token") }; - await _paymentServiceContract.ProccessCallBack(dto); - return Ok(); - } - [HttpGet] - public async Task ZarinPalCallBack([FromQuery] ZarinPalCallBackResponse response) - { - if (response.Status!="OK") - { - return BadRequest(response); - } + var result = await _paymentServiceContract.HandleCallBackAsync(gateway, dto); - var verifyRequest = new - { - merchant_id = _config["Payment:MerchantIdZarinPal"], - //Todo : Read from db - amount = 50000, - authority = response.Authority - }; - var verifyResponse = await _httpClient.PostAsJsonAsync("https://sandbox.zarinpal.com/pg/v4/payment/verify.json",verifyRequest); - - var result = await verifyResponse.Content.ReadFromJsonAsync(); - - // http://salon1.localhost:8596/api/Payment/ZarinPalCallBack?Authority=S00000000000000000000000000000ee1owr&Status=OK - if (result.Data.Code!=100) - return BadRequest(result); - - return Ok("پرداخت با موفقیت انجام شد شماره پیگیری "+result.Data.RefId); + return Ok(result); } - - // [HttpPost] - // public async Task VerifyZarinPal() - // { - // var request=new - // { - // merchant_id = _config["Payment:MerchantIdZarinPal"], - // amount = dto.Amount, - // description = dto.Description, - // callback_url = _config["Payment:ZarinPalCallBackUrl"] - // } - // } } \ No newline at end of file diff --git a/api/Program.cs b/api/Program.cs index e13f1fe..6d33533 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -1,3 +1,4 @@ +using System.Reflection.Metadata; using System.Security.Claims; using System.Text; using api.Middlewares;