using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Security.Cryptography;
using System.Text;
using Domain.Entities.Members;
using Domain.Entities.Store.ValueObject;
namespace Domain.Entities.Store;
///
/// 쿠폰 코드 풀의 개별 코드. Code(평문) + CodeHash(SHA-256 hex) 동시 저장.
/// 조회/UNIQUE는 CodeHash, 사용자 표시는 Code.
/// 상태 머신: Available -> Reserved (결제 진행) -> Issued (결제 완료, 회원 보관함) -> Used (실제 사용).
///
public class CouponCode
{
[ForeignKey(nameof(CouponID))]
public virtual Coupon? Coupon { get; private set; }
[ForeignKey(nameof(IssuedToMemberID))]
public virtual Member? IssuedToMember { get; private set; }
[Key]
public int ID { get; private set; }
public int CouponID { get; private set; }
/// 실제 사용자에게 노출되는 코드 평문 (예: "ABCD-1234-EFGH-5678").
public string Code { get; private set; } = default!;
/// SHA-256 hex 해시 (대문자, 64자). UNIQUE 인덱스 대상.
public string CodeHash { get; private set; } = default!;
public CouponCodeStatus Status { get; private set; } = CouponCodeStatus.Available;
public int? IssuedToMemberID { get; private set; }
public DateTime? IssuedAt { get; private set; }
public DateTime? UsedAt { get; private set; }
public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;
[Timestamp]
public byte[] RowVersion { get; private set; } = default!;
private CouponCode() { }
public static CouponCode Create(int couponID, string code)
{
if (couponID <= 0)
{
throw new ArgumentOutOfRangeException(nameof(couponID));
}
if (string.IsNullOrWhiteSpace(code))
{
throw new ArgumentException("Code is required.", nameof(code));
}
if (code.Length > 100)
{
throw new ArgumentOutOfRangeException(nameof(code), "Code must be <= 100 chars.");
}
return new CouponCode
{
CouponID = couponID,
Code = code,
CodeHash = ComputeHash(code),
Status = CouponCodeStatus.Available
};
}
///
/// CSV import 전용. 외부 시스템에서 마이그레이션할 때 사용. Used 상태로 즉시 생성.
/// IssuedAt/UsedAt 은 UTC 로 전달.
///
public static CouponCode CreateUsedForImport(
int couponID,
string code,
int memberID,
DateTime issuedAtUtc,
DateTime usedAtUtc
) {
if (couponID <= 0)
{
throw new ArgumentOutOfRangeException(nameof(couponID));
}
if (memberID <= 0)
{
throw new ArgumentOutOfRangeException(nameof(memberID));
}
if (string.IsNullOrWhiteSpace(code))
{
throw new ArgumentException("Code is required.", nameof(code));
}
if (code.Length > 100)
{
throw new ArgumentOutOfRangeException(nameof(code), "Code must be <= 100 chars.");
}
if (issuedAtUtc > usedAtUtc)
{
throw new ArgumentException("IssuedAt must be <= UsedAt.", nameof(issuedAtUtc));
}
return new CouponCode
{
CouponID = couponID,
Code = code,
CodeHash = ComputeHash(code),
Status = CouponCodeStatus.Used,
IssuedToMemberID = memberID,
IssuedAt = issuedAtUtc,
UsedAt = usedAtUtc
};
}
///
/// 결제 진행 중 사전 점유. Available -> Reserved.
/// 호출 측에서 SQL UPDATE 조건 절(`WHERE Status=Available`)로 동시성 충돌 방지 권장.
///
public void Reserve()
{
if (Status != CouponCodeStatus.Available)
{
throw new InvalidOperationException($"Cannot reserve from status {Status}.");
}
Status = CouponCodeStatus.Reserved;
}
/// 결제 실패/취소 시 Reserved -> Available 복귀.
public void ReleaseReservation()
{
if (Status != CouponCodeStatus.Reserved)
{
throw new InvalidOperationException($"Cannot release reservation from status {Status}.");
}
Status = CouponCodeStatus.Available;
}
/// 결제 완료 시 Reserved -> Issued. 회원 보관함에 발급됨.
public void Issue(int memberID)
{
if (memberID <= 0)
{
throw new ArgumentOutOfRangeException(nameof(memberID));
}
if (Status != CouponCodeStatus.Reserved)
{
throw new InvalidOperationException($"Cannot issue from status {Status}.");
}
Status = CouponCodeStatus.Issued;
IssuedToMemberID = memberID;
IssuedAt = DateTime.UtcNow;
}
/// 사용자가 보관함에서 "사용" 클릭 시 Issued -> Used.
public void MarkUsed()
{
if (Status != CouponCodeStatus.Issued)
{
throw new InvalidOperationException($"Cannot mark used from status {Status}.");
}
Status = CouponCodeStatus.Used;
UsedAt = DateTime.UtcNow;
}
/// 환불 시 Issued -> Available 재사용 가능. Used 상태에서는 환불 거부 정책.
public void RestoreToAvailable()
{
if (Status == CouponCodeStatus.Used)
{
throw new InvalidOperationException("이미 사용된 쿠폰 코드는 환불 후 재사용 불가합니다.");
}
Status = CouponCodeStatus.Available;
IssuedToMemberID = null;
IssuedAt = null;
UsedAt = null;
}
///
/// 관리자 일괄 만료. Available 또는 Issued 코드만 만료 가능.
/// Issued 출발 시 IssuedToMemberID / IssuedAt 보존 (복귀 시 회원 정보 복원에 사용).
/// Reserved (결제 진행 중) / Used (사용 완료) 코드는 정합성 보호를 위해 만료 거부.
///
public void Expire()
{
if (Status != CouponCodeStatus.Available && Status != CouponCodeStatus.Issued)
{
throw new InvalidOperationException($"Cannot expire from status {Status}. (Available 또는 Issued 만 만료 가능)");
}
Status = CouponCodeStatus.Expired;
}
///
/// 만료 코드 복귀. Expired 에서만 호출 가능.
/// UsedAt 이 있으면 거부 (이중 안전 — 실제로는 Used 가 만료된 적이 없어 항상 null 이어야 함).
/// IssuedToMemberID 가 있으면 Issued 로, 없으면 Available 로 복귀.
///
public void RestoreFromExpired()
{
if (Status != CouponCodeStatus.Expired)
{
throw new InvalidOperationException($"Cannot restore from status {Status}. (Expired 만 복귀 가능)");
}
if (UsedAt.HasValue)
{
throw new InvalidOperationException("이미 사용된 쿠폰 코드는 복귀할 수 없습니다.");
}
Status = IssuedToMemberID.HasValue ? CouponCodeStatus.Issued : CouponCodeStatus.Available;
}
private static string ComputeHash(string code)
{
var bytes = Encoding.UTF8.GetBytes(code);
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash);
}
}