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}";
}
}