Angular 17 & .NET 8 - LINE Pay

文章目錄

準備工作

  1. 前往Line Pay 技術支援建立 Sandbox。 建立 Line Pay Sandbox

  2. 信箱取得帳號資訊,LINE Pay Sandbox ID 與 PW。 信箱取得 Line Pay Sandbox ID 與密碼

  3. 使用信箱中的帳號資訊,登入 Merchant Center 取得 Channel ID 與 Channel Secret Key。

    • 選擇左側選單中的「管理付款連結」,接著點擊「管理連結金鑰」。
    • 點擊「查詢」,前往信箱取得驗證碼確認。

取得 Line Pay Channel ID 與 Channel Secret Key

  1. 在 appsettings.json 中設定參數,並將 appsettings.json 加入 .gitignore ,以防 secret 洩漏。

    • 依環境設置BaseURL
      • 測試環境:https://sandbox-api-pay.line.me
      • 生產環境:https://api-pay.line.me
    "LinePay": {
        "ClientId": "paste your Channel ID here",
        "ClientSecret": "paste your Channel Secret Key here",
        "BaseURL": "https://sandbox-api-pay.line.me",
        "ConfirmURL": "http://localhost:7300/api/LinePay",
        "CancelURL": "http://localhost:4200/cust-order-detail"
    }
    

建立 LinePayService

  • 注入 IConfiguration 取得參數。
  • 注入 IHttpClientFactory 發送 HTTP 請求。
  • _jsonOptions 將 JSON 屬性名稱轉換為小駝峰。
public class LinePayService : ILinePayService
{
    private readonly string _clientId;
    private readonly string _clientSecret;
    private readonly string _linePayBaseURL;
    private readonly string _confirmURL;
    private readonly string _cancelURL;
    private readonly IHttpClientFactory _httpClient;
    private readonly JsonSerializerOptions _jsonOptions = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase
    };

    public LinePayService(IConfiguration config, IHttpClientFactory httpClient)
    {
        _clientId = config["LinePay:ClientId"] ?? throw new NullReferenceException();
        _clientSecret = config["LinePay:ClientSecret"] ?? throw new NullReferenceException();
        _linePayBaseURL = config["LinePay:BaseURL"] ?? throw new NullReferenceException();
        _confirmURL = config["LinePay:ConfirmURL"] ?? throw new NullReferenceException();
        _cancelURL = config["LinePay:CancelURL"] ?? throw new NullReferenceException();
        _httpClient = httpClient;
    }
}

API authentication

發送 LINE Pay 請求前 Header 必須包含:

  • Content-Typeapplication/json
  • X-LINE-ChannelId:your Channel ID
  • X-LINE-Authorization-NonceUUID or timestamp
  • X-LINE-Authorization:HMAC Base64 Signature

Generate Hmac Base64 Signature

private static string Encrypt(string secretKey, string value)
{
    Encoding ascii = Encoding.ASCII;
    HMACSHA256 hmac = new (ascii.GetBytes(secretKey));
    return Convert.ToBase64String(hmac.ComputeHash(ascii.GetBytes(value)));
}

Request

發送 LINE Pay 請求,倒轉至 LINE Pay 頁面。

linePay(order: OrderHeader) {
return this._http.post(this._env.APIEndpoint.LinePay, order)
            .subscribe((res: any) => location.href = res.info.paymentUrl.web);
}

透過 Request API 將使用者的購買資訊發送給 LINE Pay,以取得付款網址並倒轉至 LINE Pay 頁面,使用者即可確認訂單資訊並選擇支付方式。

接著,使用者是否成功支付的結果,將會發送至 _confirmURL ,並取得 transactionId 可用來完成支付或處理退款。

[HttpPost]
public async Task<IActionResult> PaymentRequestAsync([FromBody] OrderHeader order)
{
    try
    {
        return Ok(await _service.RequestPaymentAsync(order));
    }
    catch (Exception ex)
    {
        Log.Error(ex.Message);
        return StatusCode(StatusCodes.Status500InternalServerError);
    }
}

public async Task<PaymentResponse> RequestPaymentAsync(OrderHeader order)
{
    var url = "/v3/payments/request";
    var form = CreatePayment(order);
    var data = JsonSerializer.Serialize(form, _jsonOptions);
    var nonce = Guid.NewGuid().ToString();
    var signature = Encrypt(_clientSecret, $"{_clientSecret}{url}{data}{nonce}");

    var content = new StringContent(data, Encoding.UTF8, new MediaTypeHeaderValue("application/json"));

    using HttpClient client = _httpClient.CreateClient();
    client.DefaultRequestHeaders.Add("X-LINE-ChannelId", _clientId);
    client.DefaultRequestHeaders.Add("X-LINE-Authorization-Nonce", nonce);
    client.DefaultRequestHeaders.Add("X-LINE-Authorization", signature);
    
    var response = await client.PostAsync($"{_linePayBaseURL}{url}", content);
    var resContent = await response.Content.ReadAsStringAsync();
    var res = JsonSerializer.Deserialize<PaymentResponse>(resContent, _jsonOptions)
                ?? throw new Exception("Null PaymentResponse");

    if (res.ReturnCode != "0000")
    {
        throw new Exception($"{res.ReturnCode}:{res.ReturnMessage}");
    }

    return res;
}

Create Request body

構建請求資料,須注意 OrderId 不可重複。

private PaymentRequest CreatePayment(OrderHeader order)
{
    var form = new PaymentRequest
    {
        Amount = order.OrderAmount,
        OrderId = $"{order.Id}_{DateTime.Now:yy/MM/dd HH:mm:ss}"
    };

    var productPackageForm = new Package
    {
        Id = $"{order.Id}",
        Name = $"{order.AppUserId}",
        Amount = order.OrderAmount
    };

    foreach (var detail in order.OrderDetails)
    {
        var product = detail.Product;
        if (detail != null && product != null)
        {
            var productForm = new Models.LinePay.Product
            {
                Id = $"{product.Id}",
                Name =  $"{product.Name}",
                Quantity = detail!.Count,
                Price = detail.UnitPrice
            };
            productPackageForm.Products.Add(productForm);
        }
    }

    form.Packages.Add(productPackageForm);

    var redirectUrls = new RedirectUrls
    {
        AppPackageName = "Cafe",
        ConfirmUrl = _confirmURL,
        CancelUrl = $"{_cancelURL}/{order.Id}"
    };

    form.RedirectUrls = redirectUrls;

    return form;
} 

Confirm

建立一個 API 端點來接收回應並取得 transactionIdorderId,該 API 端點即為 _confirmURL 的值。

使用 orderId 自資料庫取得訂單資訊,並在付款成功後修改資料庫中的訂單付款資訊。

[HttpGet]
public async Task<IActionResult> PaymentConfirmAsync([FromQuery] string transactionId, [FromQuery] string orderId)
{
    try
    {
        var oId = int.Parse(orderId.Split('_')[0]);
        var order = await _orderService.FindAsync(oId);
        var res = await _service.ConfirmPaymentAsync(transactionId, order.OrderAmount);
        order.OrderNo = res.Info.TransactionId.ToString();

        string payMethod = string.Join(",", res.Info.PayInfo.Select(p => p.Method));
        order.PaymentType = $"LinePay-{payMethod}";
        order.PaymentTypeChargeFee = (int)res.Info.Packages.Sum(_ => _.UserFeeAmount);
        order.PaymentStatus = PaymentStatus.Paid;
        order.PaymentTime = DateTime.Now;
        await _orderService.UpdateAsync(order);

        return Redirect($"http://localhost:4200/cust-order-detail/{order.Id}");
    }
    catch (Exception ex)
    {
        Log.Error(ex.Message);
        return StatusCode(StatusCodes.Status500InternalServerError);
    }
}
public async Task<ConfirmResponse> ConfirmPaymentAsync(string transactionId, int amount)
{
    string url = $"/v3/payments/{transactionId}/confirm";
    string data = JsonSerializer.Serialize(new { amount, currency = "TWD" }, _jsonOptions);
    var content = new StringContent(data, Encoding.UTF8, "application/json");
    var nonce = Guid.NewGuid().ToString();
    var signature = Encrypt(_clientSecret, $"{_clientSecret}{url}{data}{nonce}");

    using var client = new HttpClient();
    client.DefaultRequestHeaders.Add("X-LINE-ChannelId", _clientId);
    client.DefaultRequestHeaders.Add("X-LINE-Authorization-Nonce", nonce);
    client.DefaultRequestHeaders.Add("X-LINE-Authorization", signature);

    var response = await client.PostAsync($"{_linePayBaseURL}{url}", content);
    string resContent = await response.Content.ReadAsStringAsync();
    var res = JsonSerializer.Deserialize<ConfirmResponse>(resContent, _jsonOptions)
                ?? throw new Exception("Null PaymentResponse");

    if (res.ReturnCode != "0000")
    {
        throw new Exception($"{res.ReturnCode}:{res.ReturnMessage}");
    }

    return res;
}

參考資料