using System.Text; using System.Text.Json; using Application.Abstractions.Crypto; using Application.Abstractions.Data; using Application.Abstractions.Payment; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace Infrastructure.Payment; /// /// 토스페이먼츠 V2 API 클라이언트. /// - 인증: Basic base64(secretKey + ":") — 콜론 1개 필수, Encoding.UTF8.GetBytes 는 BOM 미포함. /// - POST 에 Idempotency-Key(UUID v4) 부여 — 동일 키 재요청 시 첫 응답 반환(중복 승인 방지). /// - 통신 불명 예외(HttpRequestException/TaskCanceledException)는 전파 — 호출측이 NeedsReconciliation 분기. (Phase 1.5) /// - 명시적 거절(HTTP 4xx/5xx + {code, message} 본문)은 Success=false 로 반환. /// internal sealed class TossPaymentService( HttpClient httpClient, IAppDbContext db, IFieldEncryptor encryptor, ILogger logger ) : ITossPaymentService { private const string BaseUrl = "https://api.tosspayments.com"; public async Task GetClientConfigAsync(CancellationToken ct) { var (clientKey, _) = await GetKeysAsync(ct); return new TossClientConfig(clientKey); } public async Task ConfirmAsync(string paymentKey, string orderID, int amount, CancellationToken ct) { var (_, secretKey) = await GetKeysAsync(ct); var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/v1/payments/confirm"); request.Headers.Add("Authorization", BuildBasicAuth(secretKey)); request.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString()); var body = new { paymentKey, orderId = orderID, amount }; request.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); var response = await httpClient.SendAsync(request, ct); var json = await response.Content.ReadAsStringAsync(ct); logger.LogInformation("[TossPay] Confirm response: {StatusCode} {Body}", response.StatusCode, json); return Parse(response.IsSuccessStatusCode, json); } public async Task CancelAsync(string paymentKey, string cancelReason, int? cancelAmount, CancellationToken ct) { var (_, secretKey) = await GetKeysAsync(ct); var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/v1/payments/{Uri.EscapeDataString(paymentKey)}/cancel"); request.Headers.Add("Authorization", BuildBasicAuth(secretKey)); request.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString()); // cancelAmount 미지정 = 전액 취소 (필드 자체를 보내지 않는다) var json = cancelAmount is null ? JsonSerializer.Serialize(new { cancelReason }) : JsonSerializer.Serialize(new { cancelReason, cancelAmount }); request.Content = new StringContent(json, Encoding.UTF8, "application/json"); var response = await httpClient.SendAsync(request, ct); var responseJson = await response.Content.ReadAsStringAsync(ct); logger.LogInformation("[TossPay] Cancel response: {StatusCode} {Body}", response.StatusCode, responseJson); return Parse(response.IsSuccessStatusCode, responseJson); } public async Task GetPaymentAsync(string paymentKey, CancellationToken ct) { var (_, secretKey) = await GetKeysAsync(ct); // GET 은 Idempotency-Key 미적용 (무시됨) var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUrl}/v1/payments/{Uri.EscapeDataString(paymentKey)}"); request.Headers.Add("Authorization", BuildBasicAuth(secretKey)); var response = await httpClient.SendAsync(request, ct); var json = await response.Content.ReadAsStringAsync(ct); logger.LogInformation("[TossPay] GetPayment response: {StatusCode} paymentKey={PaymentKey}", response.StatusCode, paymentKey); return Parse(response.IsSuccessStatusCode, json); } // ── Private ────────────────────────────────────────────────────── private static TossPaymentResult Parse(bool httpSuccess, string json) { JsonElement doc; try { doc = JsonSerializer.Deserialize(json); } catch (JsonException ex) { // 게이트웨이 HTML 오류 등 비정상 본문 — 실패 단정 금지, 통신 불명으로 전파해 대사 대상으로 처리 throw new HttpRequestException($"토스 응답 본문 파싱 실패: {ex.Message}", ex); } string? Get(JsonElement el, string key) => el.ValueKind == JsonValueKind.Object && el.TryGetProperty(key, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null; int? GetInt(JsonElement el, string key) => el.ValueKind == JsonValueKind.Object && el.TryGetProperty(key, out var p) && p.ValueKind == JsonValueKind.Number ? p.GetInt32() : null; DateTime? GetDate(JsonElement el, string key) { var raw = Get(el, key); return DateTimeOffset.TryParse(raw, out var parsed) ? parsed.UtcDateTime : null; } if (!httpSuccess) { // 에러 응답 {code, message} return new TossPaymentResult( false, Get(doc, "code") ?? "UNKNOWN", Get(doc, "message") ?? "결제 요청에 실패했습니다.", Get(doc, "paymentKey"), Get(doc, "orderId"), null, null, null, null, null, null, null, null, null, [], json ); } // 영수증 string? receiptUrl = null; if (doc.TryGetProperty("receipt", out var receipt)) { receiptUrl = Get(receipt, "url"); } // 가상계좌 string? vaNumber = null; string? vaBankCode = null; string? vaHolder = null; DateTime? vaDueDate = null; if (doc.TryGetProperty("virtualAccount", out var va) && va.ValueKind == JsonValueKind.Object) { vaNumber = Get(va, "accountNumber"); vaBankCode = Get(va, "bankCode") ?? Get(va, "bank"); vaHolder = Get(va, "customerName"); vaDueDate = GetDate(va, "dueDate"); } // 취소 이력 var cancels = new List(); if (doc.TryGetProperty("cancels", out var cancelArray) && cancelArray.ValueKind == JsonValueKind.Array) { foreach (var c in cancelArray.EnumerateArray()) { cancels.Add(new TossCancelDetail(Get(c, "transactionKey"), GetInt(c, "cancelAmount") ?? 0, Get(c, "cancelReason"), GetDate(c, "canceledAt"))); } } return new TossPaymentResult( true, null, null, Get(doc, "paymentKey"), Get(doc, "orderId"), Get(doc, "status"), GetInt(doc, "totalAmount"), Get(doc, "method"), GetDate(doc, "approvedAt"), receiptUrl, vaNumber, vaBankCode, vaHolder, vaDueDate, cancels, json ); } private async Task<(string ClientKey, string SecretKey)> GetKeysAsync(CancellationToken ct) { var config = await db.Config.FirstOrDefaultAsync(ct); if (config?.External is null) { throw new InvalidOperationException("토스페이먼츠 결제 설정이 없습니다. 관리자 페이지에서 설정해주세요."); } var ext = config.External; var isLive = string.Equals(ext.TossPayMode, "live", StringComparison.OrdinalIgnoreCase); var clientKey = isLive ? ext.TossLiveClientKeyEnc : ext.TossTestClientKeyEnc; var secretKey = isLive ? ext.TossLiveSecretKeyEnc : ext.TossTestSecretKeyEnc; if (string.IsNullOrEmpty(clientKey) || string.IsNullOrEmpty(secretKey)) { throw new InvalidOperationException($"토스페이먼츠 {(isLive ? "라이브" : "테스트")} 설정이 불완전합니다."); } // Admin 결제 설정 화면이 "enc:v{n}:" 포맷으로 암호화 저장 — 레거시 평문은 그대로 통과 return (EncryptedConfigValue.Unprotect(encryptor, clientKey), EncryptedConfigValue.Unprotect(encryptor, secretKey)); } private static string BuildBasicAuth(string secretKey) { // SecretKey + ":" → Base64 (콜론 1개 필수, BOM 없이 인코딩) var raw = $"{secretKey}:"; var base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(raw)); return $"Basic {base64}"; } }