feat(api): .NET 10 multi-tenant REST API
Full backend implementation: - Multi-tenant cafe/restaurant management (menus, orders, tables, staff) - POS order flow with ZarinPal and Snappfood payment integration - OTP authentication via Kavenegar SMS - QR digital menu with public discover/finder endpoints - Customer loyalty, coupons, CRM - PostgreSQL via EF Core, Redis for caching/sessions - Background jobs, webhook handlers - Full migration history Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
using Meezi.API.Models.Crm;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Meezi.Shared;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public interface ICouponService
|
||||
{
|
||||
Task<IReadOnlyList<CouponDto>> GetAllAsync(string cafeId, CancellationToken cancellationToken = default);
|
||||
Task<CouponDto?> GetAsync(string cafeId, string id, CancellationToken cancellationToken = default);
|
||||
Task<CouponDto?> CreateAsync(string cafeId, CreateCouponRequest request, CancellationToken cancellationToken = default);
|
||||
Task<CouponDto?> UpdateAsync(string cafeId, string id, UpdateCouponRequest request, CancellationToken cancellationToken = default);
|
||||
Task<bool> DeleteAsync(string cafeId, string id, CancellationToken cancellationToken = default);
|
||||
Task<(ValidateCouponResult? Data, ApiError? Error)> ValidateAsync(
|
||||
string cafeId,
|
||||
ValidateCouponRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class CouponService : ICouponService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public CouponService(AppDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CouponDto>> GetAllAsync(string cafeId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var list = await _db.Coupons
|
||||
.Where(c => c.CafeId == cafeId)
|
||||
.OrderByDescending(c => c.CreatedAt)
|
||||
.ToListAsync(cancellationToken);
|
||||
return list.Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<CouponDto?> GetAsync(string cafeId, string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _db.Coupons
|
||||
.FirstOrDefaultAsync(c => c.Id == id && c.CafeId == cafeId, cancellationToken);
|
||||
return entity is null ? null : ToDto(entity);
|
||||
}
|
||||
|
||||
public async Task<CouponDto?> CreateAsync(
|
||||
string cafeId,
|
||||
CreateCouponRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var codeExists = await _db.Coupons.AnyAsync(
|
||||
c => c.CafeId == cafeId && c.Code == request.Code.ToUpperInvariant(), cancellationToken);
|
||||
if (codeExists) return null;
|
||||
|
||||
var entity = new Coupon
|
||||
{
|
||||
CafeId = cafeId,
|
||||
Code = request.Code.ToUpperInvariant(),
|
||||
Type = request.Type,
|
||||
Value = request.Value,
|
||||
MinOrderAmount = request.MinOrderAmount,
|
||||
MaxDiscount = request.MaxDiscount,
|
||||
UsageLimit = request.UsageLimit,
|
||||
TargetGroup = request.TargetGroup,
|
||||
StartsAt = request.StartsAt,
|
||||
ExpiresAt = request.ExpiresAt,
|
||||
IsActive = request.IsActive
|
||||
};
|
||||
|
||||
_db.Coupons.Add(entity);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return ToDto(entity);
|
||||
}
|
||||
|
||||
public async Task<CouponDto?> UpdateAsync(
|
||||
string cafeId,
|
||||
string id,
|
||||
UpdateCouponRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _db.Coupons
|
||||
.FirstOrDefaultAsync(c => c.Id == id && c.CafeId == cafeId, cancellationToken);
|
||||
if (entity is null) return null;
|
||||
|
||||
if (request.Code is not null) entity.Code = request.Code.ToUpperInvariant();
|
||||
if (request.Type.HasValue) entity.Type = request.Type.Value;
|
||||
if (request.Value.HasValue) entity.Value = request.Value.Value;
|
||||
if (request.MinOrderAmount.HasValue) entity.MinOrderAmount = request.MinOrderAmount;
|
||||
if (request.MaxDiscount.HasValue) entity.MaxDiscount = request.MaxDiscount;
|
||||
if (request.UsageLimit.HasValue) entity.UsageLimit = request.UsageLimit;
|
||||
if (request.TargetGroup.HasValue) entity.TargetGroup = request.TargetGroup;
|
||||
if (request.StartsAt.HasValue) entity.StartsAt = request.StartsAt;
|
||||
if (request.ExpiresAt.HasValue) entity.ExpiresAt = request.ExpiresAt;
|
||||
if (request.IsActive.HasValue) entity.IsActive = request.IsActive.Value;
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return ToDto(entity);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(string cafeId, string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _db.Coupons
|
||||
.FirstOrDefaultAsync(c => c.Id == id && c.CafeId == cafeId, cancellationToken);
|
||||
if (entity is null) return false;
|
||||
|
||||
entity.DeletedAt = DateTime.UtcNow;
|
||||
entity.IsActive = false;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<(ValidateCouponResult? Data, ApiError? Error)> ValidateAsync(
|
||||
string cafeId,
|
||||
ValidateCouponRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var code = request.Code.Trim().ToUpperInvariant();
|
||||
if (string.IsNullOrEmpty(code))
|
||||
return (null, new ApiError("COUPON_REQUIRED", "Coupon code is required."));
|
||||
|
||||
if (request.Subtotal <= 0)
|
||||
return (null, new ApiError("CART_EMPTY", "Add items before applying a coupon."));
|
||||
|
||||
var coupon = await _db.Coupons.FirstOrDefaultAsync(
|
||||
c => c.CafeId == cafeId && c.Code == code,
|
||||
cancellationToken);
|
||||
|
||||
if (coupon is null)
|
||||
return (null, new ApiError("COUPON_NOT_FOUND", "Coupon code is invalid."));
|
||||
|
||||
if (!coupon.IsActive || coupon.DeletedAt is not null)
|
||||
return (null, new ApiError("COUPON_INACTIVE", "This coupon is not active."));
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
if (coupon.StartsAt is not null && coupon.StartsAt > now)
|
||||
return (null, new ApiError("COUPON_NOT_STARTED", "This coupon is not valid yet."));
|
||||
|
||||
if (coupon.ExpiresAt is not null && coupon.ExpiresAt < now)
|
||||
return (null, new ApiError("COUPON_EXPIRED", "This coupon has expired."));
|
||||
|
||||
if (coupon.UsageLimit is not null && coupon.UsedCount >= coupon.UsageLimit)
|
||||
return (null, new ApiError("COUPON_LIMIT_REACHED", "This coupon has reached its usage limit."));
|
||||
|
||||
if (coupon.MinOrderAmount is not null && request.Subtotal < coupon.MinOrderAmount)
|
||||
return (null, new ApiError(
|
||||
"COUPON_MIN_ORDER",
|
||||
$"Minimum order amount is {coupon.MinOrderAmount:N0} T."));
|
||||
|
||||
var discount = CalculateDiscount(coupon, request.Subtotal);
|
||||
if (discount <= 0)
|
||||
return (null, new ApiError("COUPON_NO_DISCOUNT", "This coupon does not apply to this order."));
|
||||
|
||||
return (new ValidateCouponResult(
|
||||
coupon.Id,
|
||||
coupon.Code,
|
||||
coupon.Type,
|
||||
coupon.Value,
|
||||
discount), null);
|
||||
}
|
||||
|
||||
internal static decimal CalculateDiscount(Coupon coupon, decimal subtotal)
|
||||
{
|
||||
return coupon.Type switch
|
||||
{
|
||||
CouponType.Percentage => Math.Min(
|
||||
Math.Round(subtotal * coupon.Value / 100m, 0),
|
||||
coupon.MaxDiscount ?? subtotal),
|
||||
CouponType.FixedAmount => Math.Min(coupon.Value, subtotal),
|
||||
_ => 0m
|
||||
};
|
||||
}
|
||||
|
||||
private static CouponDto ToDto(Coupon c) => new(
|
||||
c.Id,
|
||||
c.Code,
|
||||
c.Type,
|
||||
c.Value,
|
||||
c.MinOrderAmount,
|
||||
c.MaxDiscount,
|
||||
c.UsageLimit,
|
||||
c.UsedCount,
|
||||
c.TargetGroup,
|
||||
c.StartsAt,
|
||||
c.ExpiresAt,
|
||||
c.IsActive);
|
||||
}
|
||||
Reference in New Issue
Block a user