/ExpensePaymentSystem

An expense payment system api project with .NET

Primary LanguageC#

Masraf Ödeme Sistemi

Patika ve Akbank tarafından düzenlenen .NET Bootcamp'ine ait bitirme projesi olan uygulama

Geliştirme Amacı

Sahada çalışan personele ait masrafların takip edilmesi ve yönetimi için geliştirdiğim uygulama, personele sahada çalıştıkları süre zarfında masraflarını girebilecekleri ve aynı zamanda yöneticilerin de vakit kaybetmeden harcamayı onaylayıp, gecikme yaşamadan personele ödeme yapabilme imkanı sunuyor.

Masraflar ile alakalı; her bir masraf için dökümanların ayrı ayrı ele alınması Çalışanın evrak, fiş, fatura toplamak zorunda kalması durumunu da epey kolaylaştırıyor.

API Dökümantasyonu

https://documenter.getpostman.com/view/28176839/2s9YsT7USt

Uygulamanın çalıştırılması

Migrationlar FinalCase.Data projesinde yer alır, FinalCase.Api projesinde yer alan appsettings.json üzerinden sql server ve diğer alanlar için gerekli alanları belirtmelisiniz. EmailFunctions projesi de RabbitMq için queue dinlemeye ihtiyaç duyar içerisinde yer alan appsettings.json dosyasına değerler uygun şekilde eklenmelidir.

Migrationları database e uygulamak için

solution dizininde

dotnet ef database update --project "./FinalCase.Data" --startup-project "./FinalCase.Api"

komutunu uygulayabilirsiniz.


FinalCase.Api & EmailFunction & BankingSystem birlikte çalışıyor olmalıdır.
dotnet run ./EmailFunctions
dotnet run ./BankingSystem
dotnet run ./FinalCase.Api

komutlarını uygulayabilirsiniz.

Seed Datalar için Seed Datalar

Roller

Uygulama Admin ve Employee olarak 2 farklı rol içerir.

ApplicationUser ismiyle veri tabanı üzerinde tutulan bir tabloda kullanıcı kayıtları yer alır. Hassas bilgiler, örneğin şifre doğrudan değil şifrelenerek tutulmaktadır.

Roller uygulama seviyesinde birbirinden ayrılmıştır. Bu sayede yönetici ile alakalı istekler gönderildiğinde, employee için kullanılırken yönetici için kullanılmayan Iban alanı ile alakalı veri dönülmez.

Mimari Tasarım

Projeyi oluştururken, bootcamp sürecinde kullanmış olduğumuz proje yapısını uyguladım ve geliştirmeler yaptım.

foldering

Robert C. Martin'in öne sürdüğü "Screaming Architecture" kavramı, bir binanın veya bir havaalanının mimari yapısının uzaktan bakıldığında kendi amacını açıkça ifade ettiği gibi, yazılımın da benzer bir açıklık ve bütünlük ile kendi yapısını ortaya koymasının faydalı olduğu fikrini ifade eder.

foldering

Proje kapsamında gerekli işlevler birbirinden ayrı olarak birer feature olarak CQRS ve Mediator tasarım desenleriyle birlikte uyguladım. Mediator implementasyonu için yaygın kullanılan bir kütüphane olan MediatR kullandım.

Okuma yapılan kısımlarda Entity Framework'ü tracking mekanizmasını da göz önüne alarak kullanmaya ve projection yapmaya çalıştım. Yalnızca okuma gerçekleştirilen her bir feature'ı, okumaya özgü optimize etmeye çalıştım. Böylelikle CQRS in uygulamadaki varlığı da arttırılmış oldu.


Method çağrıları yapılırken ReadOnly operasyonlarda ``AsNoTracking()`` ve ``AsNoTrackingWithIdentityResolution()`` gibi methodlar sorgular ile birlikte kullandım.

Veri tabanından yüklü miktarda veri okumak yerine, Select() ve AutoMapper kütüphanesinin queryable extensionları arasında yer alan ProjectTo<>() extension method'u kullanarak projection yaptım.

foldering foldering

Bu noktada Select() methodunun sonrasında AsNoTracking() methodunun çağrılamadığını fakat ProjectTo() methodunun buna müsade ettiğini, bu yüzden; olası bir performans avantajı elde etme düşüncesiyle 2. görselde görülebileceği gibi bir çağrı yaptığımı belirtmek isterim.


Class Diagram

foldering

Payment

Raporlama, join işlemlerini azaltma ve ödeme oluşturma gibi işlevler için oluşturduğum class.

PaymentMethod

Ödeme yöntemleri ile alakalı class yapısı

ExpenseCategory

Masraf kategorileri için oluşturuldu.

Documents

Dökümanlar için oluşturuldu.

Expense

Harcamalar için oluşturduğum class.

ApplicationUser

Kullanıcılar için oluşturuldu, role için string türünde bir property içerir.


Entity Configurations

BaseEntity ile alakalı konfigürasyonları tek bir noktada konumlandırmak için BaseEntityConfiguration abstract class olarak oluşturuldu
Configure methodu kendisini kalıtım alan classlar tarafından override edilebilmesi için virtual olarak işaretlendi, türeyen classlar bu method içerisinde, base de yer alan implementasyonu çağırabilir ve bunun yanında kendi implementasyonlarını (bizim durumumuzda konfigürasyonlarını) gerçekleştirebilir.

foldering

Kullanıcılara Özgü İşlemler

Saha personelleri masraf girişini, yalnızca kendileri üzere olmak için gerçekleştirebiliyor.

Json Web Token yapısından faydalanarak bu özelliği ekledim. Claims üzerinden kullanıcı rol ve identifier bilgilerine erişerek, yapıyı kurguladım.

[HttpPost]
[Authorize(Roles = Roles.Employee)]
public async Task<ApiResponse<ExpenseResponse>> CreateExpense([FromBody] ExpenseRequest request)
{
    var (employeeId, _) = GetUserIdAndRoleFromClaims(User.Identity as ClaimsIdentity); // to add InsertUserId
    var operation = new CreateExpenseCommand(employeeId, request);
    return await mediator.Send(operation);
}

GetUserIdAndRoleFromClaims isimli tuple dönen bir static helper oluşturarak JWT üzerinden id ve role okuması gerçekleştirdim. Bu methodu gerektiği durumda çağırarak kullanıcıdan expense oluşturma sırasında EmployeeId ile alakalı değer girişi yapmasını beklemeden, kullandığı tokenı okuyarak id değerini expense oluşturma esnasında geçebiliyorum.

namespace FinalCase.Api.Helpers;
public static class ClaimsHelper
{
    /// <summary>
    /// Get the user id and role from the claims if they exist
    /// </summary>
    /// <param name="identity">The claim identity</param>
    /// <param name="idClaimType">ID claim </param>
    /// <param name="roleClaimType">Role claim </param>
    /// <returns>the user id and role, tuple</returns>
    public static (int UserId, string Role) GetUserIdAndRoleFromClaims(ClaimsIdentity identity,
        string idClaimType = JwtPayloadFields.Id, string roleClaimType = ClaimTypes.Role)
    {
        var idClaim = identity.FindFirst(idClaimType);
        var roleClaim = identity.FindFirst(roleClaimType);

        if (idClaim == null || roleClaim == null)
            throw new ArgumentException("Invalid Claims");

        return (int.Parse(idClaim.Value), roleClaim.Value);
    }
}

JWTPayload için ise doğrudan string değerler kullanmak yerine ayrı bir class oluşturdum.

namespace FinalCase.Business.Features.Authentication.Constants.Jwt;
public static class JwtPayloadFields
{
    // created to prevent magic strings
    // if values changed somehow, we can change here and it will be reflected everywhere
    // if the value deleted, then it will be a compile time error. So, we can't forget to change it everywhere
    public const string Id = "Id";
    public const string Email = "Email";
    public const string Username = "Username";
}

Roller de benzer şekilde constant string olarak ele alınıyor.

namespace FinalCase.Business.Features.Authentication.Constants.Roles;
public static class Roles
{
    public const string Admin = "admin";
    public const string Employee = "employee";
}

Personel sadece kendi masraf tanımlarını görebiliyor

Yine bu özelliği de benzer şekilde ekledim.

ExpensesController.cs

[HttpGet]
[Authorize(Roles = $"{Roles.Employee},{Roles.Admin}")]
[EmployeeIdFromQueryAuthorize]
public async Task<ApiResponse<IEnumerable<ExpenseResponse>>> GetByParameter([FromQuery] GetExpensesQueryParameters parameters)
{
    var operation = new GetExpensesByParameterQuery(parameters);
    return await mediator.Send(operation);
}

[HttpGet("{id:min(1)}")]
[Authorize(Roles = $"{Roles.Employee},{Roles.Admin}")]
public async Task<ApiResponse<ExpenseResponse>> GetById(int id)
{
    var (userId, role) = GetUserIdAndRoleFromClaims(User.Identity as ClaimsIdentity);
    var operation = new GetExpenseByIdQuery(userId, role, id);
    return await mediator.Send(operation);
}

GetExpenseByIdQueryQueryHandler.cs

foldering

Her bir Feature altında yer alacak şekilde; hata mesajları constant string olarak tutuluyor.

namespace FinalCase.Business.Features.Expenses.Constants;
public static class ExpenseMessages
{
    public const string ExpenseNotFound = "Expense not found.";

    public const string CompletedUpdateError = "Cannot update a completed expense";
    public const string RejectedUpdateError = "Cannot update a rejected expense";
    public const string OnlyPendingUpdateError = "Only the 'pending' status is allowed to update an expense";

    public const string PaymentMethodNotFound = "The specified Payment Method was not found.";
    public const string CategoryNotFound = "The specified Category was not found.";

    public const string ExpenseAlreadyApprovedError = "Some expenses have already been approved or rejected. Only pending expenses can be requested. Related Ids: {0}";
    public const string ExpenseToApprovedNotFoundError = "Some expenses that were requested for approval were not found. Related Ids: {0}";


    public const string UnauthorizedExpenseUpdate = "You do not have permission to update this expense.";
    public const string UnauthorizedExpenseDelete = "You do not have permission to delete this expense.";
    public const string UnauthorizedExpenseRead = "You do not have permission to access this expense.";
}

Personel taleplerini ilgili kriterlere göre filtreleyebilir.

Filtreleme için daha önce derste bootcamp sürecindeki derslerimizde de kullandığımız LinqKit den faydalandım

ExpensesController.cs

[HttpGet]
[Authorize(Roles = $"{Roles.Employee},{Roles.Admin}")]
[EmployeeIdFromQueryAuthorize]
public async Task<ApiResponse<IEnumerable<ExpenseResponse>>> GetByParameter([FromQuery] GetExpensesQueryParameters parameters)
{
    var operation = new GetExpensesByParameterQuery(parameters);
    return await mediator.Send(operation);
}

GetExpenseByParameterQuery.cs

foldering

GetExpenseByParameterQueryHandler.cs

foldering

Personel ret olan talepleri için neden ret olduklarına dair açıklama görebilir.

api/Expenses/reject söz konusu endpoint expense id ve admin description'ı dizi olarak alır bu sayede reddedilen her bir harcama için red sebebi eklenmesi mümkündür.

foldering

Onaylanan talepler için ödeme banka hesabına anında geçecek bir hayali ödeme sistemi tasarlanabilir. Admin rolüne sahip kullanıcılar tüm personelin taleplerini görebilir ve talepleri değerlendirip onaylayıp otomatik ödeme talimatı girişi sağlar ya da bir neden ile talebi reddederler.

Banka sistemini simüle etmek adına bir başka api projesi oluşturdum. Bu proje bir json dosyası üzerinden gelen ödemelere ait bilgileri tutuyor.

Saha personeli tarafından oluşturulan ödeme, bir yönetici tarafından onaylandığında;

  1. Expense in status bilgisi approved a çekilirken aynı zamanda payment da oluşturulur, tek bir SaveChanges() çağrımı öncesinde gerçekleştirildikleri için için tek bir transaction oluşturulur, atomicity söz konusu.

ApproveExpensesCommandHandler.cs

foldering

Oluşturmuş olduğum constant stringleri hata mesajı şeklinde dönen methodlar da mevcut, hata dönerlerse string.Join() methodu ile - işaretini aralara ekleyerek her birini birleştiriyor. Böylece daha sonra istenirse Front end tarafında split edilerek kullanılabilir.

foldering
  1. SendPayment methodu Method Handler içerisinde oluşturulmuş Payment objelerini kullanarak banka simülasyonuna atılacak request i oluşturur. Her bir payment a karşılık; request oluşturulacak, ödeme sonrası personele iletmek adına mail objesi oluşturulacak ve job için method injection vasıtası ile notificationService inject edilecektir.
foldering

Sonrasında bir job oluşturulacak ve kullanıcıya eğer her şey yolunda ise 200 mesajı dönülecektir. Gerekli bilgileri almış olduğumu ve artık admin tarafından gelen onay isteğini daha fazla bekletmeye ihtiyaç olmadığını düşündüğüm için kalan kısmı job ile devam ettirdim.

 /// <summary>
 /// Schedules a job to send a payment request to the banking system and continues with sending if the job is completed.
 /// </summary>
 /// <param name="request">The outgoing payment request.</param>
 /// <param name="email">The email to be sent.</param>
 /// <param name="notificationService">The service instance managing notifications.</param>
 /// <param name="cancellationToken">Cancellation Token.</param>
 public static void SendPaymentRequest(OutgoingPaymentRequest request, Email email, INotificationService notificationService, CancellationToken cancellationToken)
 {
     var sendPayment = BackgroundJob.Schedule(() => SendPaymentJobAsync(request, cancellationToken), TimeSpan.FromSeconds(3)); // Schedule a job to send the payment request to the banking system.
     var sendEmail = BackgroundJob.ContinueJobWith(sendPayment, () => notificationService.SendEmail(email)); // Schedule a job to send the email to the employee.        
     BackgroundJob.ContinueJobWith(sendEmail, () => CompletePayment(request.Description, cancellationToken));
 }

SendPaymentJob, Sık kullanılan bir kütüphane olan Restsharp vasıtası ile banka simülasyonuna istek gönderir (banka simülasyonunun kendi ödemelerimiz hakkında bilgi alabilmemiz ve ödeme yapabilmemiz için bize bir api sağladığını varsayıyoruz).


İsteğin ödeme açıklamasında payment a ait id bulunur, banka simülasyonu id değerini kayıt ettiyse. Ödeme tamamlandı demektir. Idempotency bakımından düşünerek, resilience ile alakalı bir sunucu olursa (network güvenilir değildir) aynı ödeme 2. kez gönderilebilir. Bunun önüne geçmek için job önce bir post isteği gönderir ve payment a ait id yi sorar(simülasyon güvenlik gerekçesiyle get yerine post ile ödeme bilgilerini almayı tercih ediyor). Not Found yanıtı gelirse bir istek daha gönderir fakat bu kez ödeme ile alakalı endpoint e istek gönderecektir.

foldering

ilk iş başarı ile sonuçlanır ise 2. iş ile süreç devam eder

queueNotificationService üzerinden RabbitMq vasıtasıyla alakalı Email için oluşturulmuş bir queue ya mesaj gönderilir. 3rd party bir abonelik sistemi ya da bir başka herhangi bir servis mail gönderimi için kullanılabileceğinden, INotification servis isimli interface ve içerisindeki method imzası üzerinden gideriz.

RabbitMq yu dinleyen EmailFunction isimli bir konsol uygulaması mevcuttur. Mail gönderimi için Serverless çözümler de tercih edilebilirdi. Azure functions, AWS Lambda + AWS SNS gibi..

EmailFunction email için oluşturulan queue yu dinler ve mail gönderir, ayn zamanda mail içeriğini konsol ekranına yazdırmaktadır.

FinalCase uygulaması ise queue ya mesajı ilettikten hemen sonra mail gönderimini beklemeksizin, Dapper aracılığıyla bir stored procedure çalıştırarak Paymentstablosunda ilgili payment a ait değeri completed olarak değiştirir.

/// <summary>
///  Updates the payment status in the database.
/// </summary>
/// <param name="description">payment description(The payment id)</param>
public static async Task CompletePayment(string description, CancellationToken cancellationToken)
{
    var parameters = new DynamicParameters();
    parameters.Add("@Id", int.Parse(description));

    await DapperExecutor.ExecuteStoredProcedureAsync(StoredProcedures.CompletePayment, parameters, configuration.GetConnectionString(DbKeys.SqlServer), cancellationToken);
}

Raporlar

Rapor oluşturmak için stored procedure ve viewlardan yararlandım. Dapper üzerinden isteğe bağlı olarak ilgili raporlar sırasıyla elde edilebilir. Günlük haftalık ve aylık ödeme yoğunluğu bir job tarafından otomatik olarak belirli saat ve zamanlarda çalışarak, adminlere ait maillerin yer aldığı bir view e erişerek. Kayıtlı olan adminlere bu raporları mail yoluyla gönderir.

API ile alakalı dökümantasyonda Raporlar ile ilgili bilgiler de yer almaktadır.

StoredProcedure ve View isimleri constant stringler olarak ayrı birer dosyada tutuluyor. Dapper üzerinde basit şekilde view ve stored procedure işletmek için bir static method da oluşturdum.

İstenen tüm raporlar sırasıyla sırasıyla,

foldering

Document ile alakalı sorgu sonuçlarında tekrarlı veriler bulunduğunu, bunu aşmak için aşağıdaki söz konusu makaleden de faydalandığımı söylemeliyim.

https://medium.com/@nelsonciofi/the-best-way-to-store-and-retrieve-complex-objects-with-dapper-5eff32e6b29e

Bununla birlikte view çalıştırmak için oluşturdum method view değerini string olarak aldığı için, her ne kadar constant string geçerek kullanıyor olsam da. Sorguya doğrudan string ekleyen bir method, çok doğru görünmediği için reflection kullanan bir method da oluşturdum. Views class ı içerisinde yer alan her bir statik değer ile parametresinde aldığı değeri karşılaştırıyor, eğer eşleşme bulunamazsa method false dönüyor.

public static bool IsViewNameValid(string view)
{
    var fields = typeof(Views).GetFields(BindingFlags.Public | BindingFlags.Static);
    // Gets all the values of the fields in the Views class

    List<string> values = fields.Select(field => (string)field.GetValue(null)).ToList();

    return values.Any(value => value.Equals(view));
}

Dapper için yazdığım View için sorgu oluşturan methodun da bir parçası

public static IEnumerable<T> QueryView<T>(string view, string connectionString)
{
    if (!IsViewNameValid(view)) // To prevent a possible SQL injection, since the parameter is a string
        throw new ArgumentException("Invalid view name");

    using var connection = new SqlConnection(connectionString);
    connection.Open();

    return connection.Query<T>($"SELECT * FROM {view}");
}

Masraf İşlemleri

Masraf için kategorisi bilgisi ve döküman bilgisi Expense.cs isimli class içerisinde yer almaktadır.


Talep alındığında gerçekleşecek ödeme sistemi Banking System projesinde yer alıyor.

Yönetici İşlemleri

Seed Datalar

ModelBuilder için extension yazarak seed datalar oluşturdum. Test amaçlı kullanılabilirler.

Personel Ekleme

Personel ekleme işlevi admin rolü ile sınırlandırılmıştır.

[HttpPost]
[Authorize(Roles = Roles.Admin)]
public async Task<ApiResponse<EmployeeResponse>> Create(EmployeeRequest request)
{
    var (userId, _) = GetUserIdAndRoleFromClaims(User.Identity as ClaimsIdentity); // to add InsertUserId

    return await mediator.Send(new CreateEmployeeCommand(userId, request));
}

Masraf Kategorisi, Ödeme Yöntemi gibi Alanların Eklenmesi

Yalnızca yönetici tarafından gerçekleştirilebilmesi için attribute kullanıldı.

Caching

PaymentMethod ve ExpenseCategory gibi çok değişmeyeceğini düşündüğüm değerleri cache üzerinde MediatR sayesinde kullanılabilen IPipeline Behavior özelliğini kullanarak Cache e ekledim. GetById ya da GetAll gibi işlevler için okuma Cache üzerinden yapılıyor. Yine değerlerde değişiklik ya da silme gibi bir durum söz konusu olursa cache üzerinde tutulan değerler siliniyor.

Validations

Tüm requestler için validationlar oluşturdum. Regex ifadeleri de ekledim.