airwallex.go 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658
  1. package provider
  2. import (
  3. "bytes"
  4. "context"
  5. "crypto/hmac"
  6. "crypto/sha256"
  7. "encoding/hex"
  8. "encoding/json"
  9. "fmt"
  10. "io"
  11. "net/http"
  12. "net/url"
  13. "strings"
  14. "sync"
  15. "time"
  16. "github.com/Wei-Shaw/sub2api/internal/payment"
  17. "github.com/google/uuid"
  18. "github.com/shopspring/decimal"
  19. )
  20. const (
  21. airwallexDemoAPIBase = "https://api-demo.airwallex.com/api/v1"
  22. airwallexProdAPIBase = "https://api.airwallex.com/api/v1"
  23. airwallexDefaultCountry = "CN"
  24. airwallexHTTPTimeout = 15 * time.Second
  25. airwallexMaxResponseSize = 1 << 20
  26. airwallexMaxErrorSummary = 512
  27. airwallexTokenSkew = 2 * time.Minute
  28. airwallexWebhookTolerance = 5 * time.Minute
  29. airwallexEventPaymentSucceeded = "payment_intent.succeeded"
  30. airwallexEventPaymentCancelled = "payment_intent.cancelled"
  31. airwallexPaymentStatusSucceeded = "SUCCEEDED"
  32. airwallexPaymentStatusCancelled = "CANCELLED"
  33. airwallexRefundStatusReceived = "RECEIVED"
  34. airwallexRefundStatusAccepted = "ACCEPTED"
  35. airwallexRefundStatusSettled = "SETTLED"
  36. airwallexRefundStatusFailed = "FAILED"
  37. )
  38. type Airwallex struct {
  39. instanceID string
  40. config map[string]string
  41. httpClient *http.Client
  42. }
  43. type airwallexTokenState struct {
  44. mu sync.Mutex
  45. token string
  46. expiresAt time.Time
  47. }
  48. var airwallexAccessTokens sync.Map
  49. func NewAirwallex(instanceID string, config map[string]string) (*Airwallex, error) {
  50. for _, k := range []string{"clientId", "apiKey", "webhookSecret", "apiBase"} {
  51. if strings.TrimSpace(config[k]) == "" {
  52. return nil, fmt.Errorf("airwallex config missing required key: %s", k)
  53. }
  54. }
  55. cfg := cloneStringMap(config)
  56. apiBase, err := normalizeAirwallexAPIBase(cfg["apiBase"])
  57. if err != nil {
  58. return nil, err
  59. }
  60. cfg["apiBase"] = apiBase
  61. currency, err := payment.NormalizePaymentCurrency(cfg["currency"])
  62. if err != nil {
  63. return nil, fmt.Errorf("airwallex config currency: %w", err)
  64. }
  65. cfg["currency"] = currency
  66. countryCode, err := normalizeAirwallexCountryCode(cfg["countryCode"])
  67. if err != nil {
  68. return nil, err
  69. }
  70. cfg["countryCode"] = countryCode
  71. return &Airwallex{
  72. instanceID: instanceID,
  73. config: cfg,
  74. httpClient: &http.Client{Timeout: airwallexHTTPTimeout},
  75. }, nil
  76. }
  77. func normalizeAirwallexCountryCode(raw string) (string, error) {
  78. countryCode := strings.ToUpper(strings.TrimSpace(raw))
  79. if countryCode == "" {
  80. return airwallexDefaultCountry, nil
  81. }
  82. if len(countryCode) != 2 {
  83. return "", fmt.Errorf("airwallex config countryCode must be a two-letter ISO country code")
  84. }
  85. for _, ch := range countryCode {
  86. if ch < 'A' || ch > 'Z' {
  87. return "", fmt.Errorf("airwallex config countryCode must be a two-letter ISO country code")
  88. }
  89. }
  90. return countryCode, nil
  91. }
  92. func normalizeAirwallexAPIBase(raw string) (string, error) {
  93. base := strings.TrimSpace(raw)
  94. if base == "" {
  95. return "", fmt.Errorf("airwallex apiBase is required")
  96. }
  97. parsed, err := url.Parse(base)
  98. if err != nil || parsed.Scheme != "https" || parsed.Host == "" {
  99. return "", fmt.Errorf("airwallex apiBase must be an HTTPS URL")
  100. }
  101. host := strings.ToLower(parsed.Host)
  102. if host != "api-demo.airwallex.com" && host != "api.airwallex.com" {
  103. return "", fmt.Errorf("airwallex apiBase host must be api-demo.airwallex.com or api.airwallex.com")
  104. }
  105. parsed.RawQuery = ""
  106. parsed.Fragment = ""
  107. parsed.RawPath = ""
  108. parsed.Path = strings.TrimRight(parsed.Path, "/")
  109. if parsed.Path == "" {
  110. parsed.Path = "/api/v1"
  111. }
  112. if parsed.Path != "/api/v1" {
  113. return "", fmt.Errorf("airwallex apiBase path must be /api/v1")
  114. }
  115. return parsed.String(), nil
  116. }
  117. func (a *Airwallex) Name() string { return "空中云汇" }
  118. func (a *Airwallex) ProviderKey() string { return payment.TypeAirwallex }
  119. func (a *Airwallex) SupportedTypes() []payment.PaymentType {
  120. return []payment.PaymentType{payment.TypeAirwallex}
  121. }
  122. func (a *Airwallex) MerchantIdentityMetadata() map[string]string {
  123. if a == nil {
  124. return nil
  125. }
  126. metadata := map[string]string{"currency": a.currency()}
  127. if accountID := strings.TrimSpace(a.config["accountId"]); accountID != "" {
  128. metadata["account_id"] = accountID
  129. }
  130. return metadata
  131. }
  132. func (a *Airwallex) currency() string {
  133. if a == nil {
  134. return payment.DefaultPaymentCurrency
  135. }
  136. currency, err := payment.NormalizePaymentCurrency(a.config["currency"])
  137. if err != nil {
  138. return payment.DefaultPaymentCurrency
  139. }
  140. return currency
  141. }
  142. func (a *Airwallex) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
  143. amount, err := decimal.NewFromString(req.Amount)
  144. if err != nil || amount.LessThanOrEqual(decimal.Zero) {
  145. return nil, fmt.Errorf("airwallex create payment: invalid amount %s", req.Amount)
  146. }
  147. token, err := a.accessToken(ctx)
  148. if err != nil {
  149. return nil, fmt.Errorf("airwallex auth: %w", err)
  150. }
  151. currency := a.currency()
  152. requestID := airwallexDeterministicRequestID("payment-intent", req.OrderID, req.Amount, currency)
  153. payload := airwallexCreatePaymentIntentRequest{
  154. RequestID: requestID,
  155. Amount: newAirwallexRequestAmount(amount),
  156. Currency: currency,
  157. MerchantOrderID: req.OrderID,
  158. ReturnURL: req.ReturnURL,
  159. Metadata: map[string]string{
  160. "order_id": req.OrderID,
  161. },
  162. }
  163. if descriptor := strings.TrimSpace(a.config["descriptor"]); descriptor != "" {
  164. payload.Descriptor = descriptor
  165. }
  166. var intent airwallexPaymentIntent
  167. if err := a.doJSON(ctx, http.MethodPost, "/pa/payment_intents/create", token, payload, &intent); err != nil {
  168. return nil, fmt.Errorf("airwallex create payment: %w", err)
  169. }
  170. if strings.TrimSpace(intent.ID) == "" || strings.TrimSpace(intent.ClientSecret) == "" {
  171. return nil, fmt.Errorf("airwallex create payment: missing payment intent id or client secret")
  172. }
  173. return &payment.CreatePaymentResponse{
  174. TradeNo: intent.ID,
  175. ClientSecret: intent.ClientSecret,
  176. IntentID: intent.ID,
  177. Currency: currency,
  178. CountryCode: a.config["countryCode"],
  179. PaymentEnv: a.checkoutEnv(),
  180. }, nil
  181. }
  182. func (a *Airwallex) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) {
  183. intentID := strings.TrimSpace(tradeNo)
  184. if intentID == "" {
  185. return nil, fmt.Errorf("airwallex query order: missing payment intent id")
  186. }
  187. token, err := a.accessToken(ctx)
  188. if err != nil {
  189. return nil, fmt.Errorf("airwallex auth: %w", err)
  190. }
  191. var intent airwallexPaymentIntent
  192. if err := a.doJSON(ctx, http.MethodGet, "/pa/payment_intents/"+url.PathEscape(intentID), token, nil, &intent); err != nil {
  193. return nil, fmt.Errorf("airwallex query order: %w", err)
  194. }
  195. return &payment.QueryOrderResponse{
  196. TradeNo: intent.ID,
  197. Status: airwallexProviderStatus(intent.Status),
  198. Amount: intent.Amount.InexactFloat64(),
  199. Metadata: a.intentMetadata(intent, ""),
  200. }, nil
  201. }
  202. func (a *Airwallex) VerifyNotification(_ context.Context, rawBody string, headers map[string]string) (*payment.PaymentNotification, error) {
  203. if err := verifyAirwallexWebhookSignature(rawBody, headers, a.config["webhookSecret"], time.Now()); err != nil {
  204. return nil, err
  205. }
  206. var event airwallexWebhookEvent
  207. if err := json.Unmarshal([]byte(rawBody), &event); err != nil {
  208. return nil, fmt.Errorf("airwallex parse webhook: %w", err)
  209. }
  210. switch event.Name {
  211. case airwallexEventPaymentSucceeded, airwallexEventPaymentCancelled:
  212. default:
  213. return nil, nil
  214. }
  215. var intent airwallexPaymentIntent
  216. if err := json.Unmarshal(event.Data.Object, &intent); err != nil {
  217. return nil, fmt.Errorf("airwallex parse payment intent: %w", err)
  218. }
  219. if strings.TrimSpace(intent.ID) == "" || strings.TrimSpace(intent.MerchantOrderID) == "" {
  220. return nil, fmt.Errorf("airwallex webhook missing payment intent id or merchant_order_id")
  221. }
  222. status := payment.ProviderStatusFailed
  223. if event.Name == airwallexEventPaymentSucceeded {
  224. if strings.ToUpper(strings.TrimSpace(intent.Status)) != airwallexPaymentStatusSucceeded {
  225. return nil, fmt.Errorf("airwallex succeeded webhook has non-succeeded status: %s", intent.Status)
  226. }
  227. status = payment.NotificationStatusSuccess
  228. }
  229. return &payment.PaymentNotification{
  230. TradeNo: intent.ID,
  231. OrderID: intent.MerchantOrderID,
  232. Amount: intent.Amount.InexactFloat64(),
  233. Status: status,
  234. RawData: rawBody,
  235. Metadata: a.intentMetadata(intent, event.accountID()),
  236. }, nil
  237. }
  238. func (a *Airwallex) Refund(ctx context.Context, req payment.RefundRequest) (*payment.RefundResponse, error) {
  239. intentID := strings.TrimSpace(req.TradeNo)
  240. if intentID == "" {
  241. return nil, fmt.Errorf("airwallex refund missing payment intent id")
  242. }
  243. amount, err := decimal.NewFromString(req.Amount)
  244. if err != nil || amount.LessThanOrEqual(decimal.Zero) {
  245. return nil, fmt.Errorf("airwallex refund: invalid amount %s", req.Amount)
  246. }
  247. token, err := a.accessToken(ctx)
  248. if err != nil {
  249. return nil, fmt.Errorf("airwallex auth: %w", err)
  250. }
  251. payload := airwallexCreateRefundRequest{
  252. RequestID: airwallexDeterministicRequestID("refund", intentID, req.Amount),
  253. PaymentIntentID: intentID,
  254. Amount: newAirwallexRequestAmount(amount),
  255. Reason: strings.TrimSpace(req.Reason),
  256. }
  257. if payload.Reason == "" {
  258. payload.Reason = "refund"
  259. }
  260. var resp airwallexRefund
  261. if err := a.doJSON(ctx, http.MethodPost, "/pa/refunds/create", token, payload, &resp); err != nil {
  262. return nil, fmt.Errorf("airwallex refund: %w", err)
  263. }
  264. if strings.TrimSpace(resp.ID) == "" {
  265. return nil, fmt.Errorf("airwallex refund: missing refund id")
  266. }
  267. refundResp := &payment.RefundResponse{
  268. RefundID: resp.ID,
  269. Status: airwallexRefundProviderStatus(resp.Status),
  270. }
  271. if refundResp.Status != payment.ProviderStatusSuccess {
  272. return refundResp, fmt.Errorf("airwallex refund not settled: status %s", strings.ToUpper(strings.TrimSpace(resp.Status)))
  273. }
  274. return refundResp, nil
  275. }
  276. func (a *Airwallex) QueryRefund(ctx context.Context, req payment.RefundQueryRequest) (*payment.RefundResponse, error) {
  277. refundID := strings.TrimSpace(req.RefundID)
  278. if refundID == "" {
  279. return nil, fmt.Errorf("airwallex query refund: missing refund id")
  280. }
  281. token, err := a.accessToken(ctx)
  282. if err != nil {
  283. return nil, fmt.Errorf("airwallex auth: %w", err)
  284. }
  285. var resp airwallexRefund
  286. if err := a.doJSON(ctx, http.MethodGet, "/pa/refunds/"+url.PathEscape(refundID), token, nil, &resp); err != nil {
  287. return nil, fmt.Errorf("airwallex query refund: %w", err)
  288. }
  289. if strings.TrimSpace(resp.ID) == "" {
  290. resp.ID = refundID
  291. }
  292. return &payment.RefundResponse{RefundID: resp.ID, Status: airwallexRefundProviderStatus(resp.Status)}, nil
  293. }
  294. func (a *Airwallex) CancelPayment(ctx context.Context, tradeNo string) error {
  295. intentID := strings.TrimSpace(tradeNo)
  296. if intentID == "" {
  297. return nil
  298. }
  299. token, err := a.accessToken(ctx)
  300. if err != nil {
  301. return fmt.Errorf("airwallex auth: %w", err)
  302. }
  303. var intent airwallexPaymentIntent
  304. if err := a.doJSON(ctx, http.MethodPost, "/pa/payment_intents/"+url.PathEscape(intentID)+"/cancel", token, nil, &intent); err != nil {
  305. return fmt.Errorf("airwallex cancel payment: %w", err)
  306. }
  307. return nil
  308. }
  309. func (a *Airwallex) intentMetadata(intent airwallexPaymentIntent, accountID string) map[string]string {
  310. metadata := map[string]string{
  311. "currency": strings.ToUpper(strings.TrimSpace(intent.Currency)),
  312. "status": strings.ToUpper(strings.TrimSpace(intent.Status)),
  313. }
  314. if accountID = strings.TrimSpace(accountID); accountID != "" {
  315. metadata["account_id"] = accountID
  316. } else if configured := strings.TrimSpace(a.config["accountId"]); configured != "" {
  317. metadata["account_id"] = configured
  318. }
  319. return metadata
  320. }
  321. func (a *Airwallex) checkoutEnv() string {
  322. if strings.EqualFold(a.config["apiBase"], airwallexProdAPIBase) {
  323. return "prod"
  324. }
  325. return "demo"
  326. }
  327. func (a *Airwallex) accessToken(ctx context.Context) (string, error) {
  328. cacheKey := a.tokenCacheKey()
  329. rawState, _ := airwallexAccessTokens.LoadOrStore(cacheKey, &airwallexTokenState{})
  330. state, ok := rawState.(*airwallexTokenState)
  331. if !ok {
  332. return "", fmt.Errorf("airwallex auth token cache state type mismatch")
  333. }
  334. state.mu.Lock()
  335. defer state.mu.Unlock()
  336. if state.token != "" && time.Now().Add(airwallexTokenSkew).Before(state.expiresAt) {
  337. return state.token, nil
  338. }
  339. req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.config["apiBase"]+"/authentication/login", nil)
  340. if err != nil {
  341. return "", err
  342. }
  343. req.Header.Set("Content-Type", "application/json")
  344. req.Header.Set("x-client-id", a.config["clientId"])
  345. req.Header.Set("x-api-key", a.config["apiKey"])
  346. if accountID := strings.TrimSpace(a.config["accountId"]); accountID != "" {
  347. req.Header.Set("x-login-as", accountID)
  348. }
  349. body, status, err := a.do(req)
  350. if err != nil {
  351. return "", err
  352. }
  353. if status < http.StatusOK || status >= http.StatusMultipleChoices {
  354. return "", formatAirwallexAuthHTTPError(status, body)
  355. }
  356. var resp airwallexAuthResponse
  357. if err := json.Unmarshal(body, &resp); err != nil {
  358. return "", fmt.Errorf("parse authentication response: %w", err)
  359. }
  360. if strings.TrimSpace(resp.Token) == "" {
  361. return "", fmt.Errorf("authentication response missing token")
  362. }
  363. expiresAt, err := parseAirwallexTime(resp.ExpiresAt)
  364. if err != nil {
  365. expiresAt = time.Now().Add(25 * time.Minute)
  366. }
  367. state.token = resp.Token
  368. state.expiresAt = expiresAt
  369. return state.token, nil
  370. }
  371. func formatAirwallexAuthHTTPError(status int, body []byte) error {
  372. summary := summarizeAirwallexResponse(body)
  373. if status == http.StatusUnauthorized || status == http.StatusForbidden {
  374. return fmt.Errorf("authentication HTTP %d: %s; Airwallex credentials were rejected, check Client ID/API Key, API Base environment (sandbox: https://api-demo.airwallex.com/api/v1, production: https://api.airwallex.com/api/v1), and Account ID (leave it empty for single-account scoped keys)", status, summary)
  375. }
  376. return fmt.Errorf("authentication HTTP %d: %s", status, summary)
  377. }
  378. func (a *Airwallex) tokenCacheKey() string {
  379. sum := sha256.Sum256([]byte(a.config["apiKey"]))
  380. return a.config["apiBase"] + "|" + a.config["clientId"] + "|" + strings.TrimSpace(a.config["accountId"]) + "|" + hex.EncodeToString(sum[:8])
  381. }
  382. func (a *Airwallex) doJSON(ctx context.Context, method, path, token string, payload any, out any) error {
  383. var bodyReader io.Reader
  384. if payload != nil {
  385. body, err := json.Marshal(payload)
  386. if err != nil {
  387. return err
  388. }
  389. bodyReader = bytes.NewReader(body)
  390. }
  391. req, err := http.NewRequestWithContext(ctx, method, a.config["apiBase"]+path, bodyReader)
  392. if err != nil {
  393. return err
  394. }
  395. req.Header.Set("Content-Type", "application/json")
  396. if token != "" {
  397. req.Header.Set("Authorization", "Bearer "+token)
  398. }
  399. if accountID := strings.TrimSpace(a.config["accountId"]); accountID != "" {
  400. req.Header.Set("x-on-behalf-of", accountID)
  401. }
  402. body, status, err := a.do(req)
  403. if err != nil {
  404. return err
  405. }
  406. if status < http.StatusOK || status >= http.StatusMultipleChoices {
  407. return fmt.Errorf("HTTP %d: %s", status, summarizeAirwallexResponse(body))
  408. }
  409. if out == nil || len(bytes.TrimSpace(body)) == 0 {
  410. return nil
  411. }
  412. if err := json.Unmarshal(body, out); err != nil {
  413. return fmt.Errorf("parse response: %w", err)
  414. }
  415. return nil
  416. }
  417. func (a *Airwallex) do(req *http.Request) ([]byte, int, error) {
  418. client := a.httpClient
  419. if client == nil {
  420. client = &http.Client{Timeout: airwallexHTTPTimeout}
  421. }
  422. resp, err := client.Do(req)
  423. if err != nil {
  424. return nil, 0, err
  425. }
  426. defer func() { _ = resp.Body.Close() }()
  427. body, err := io.ReadAll(io.LimitReader(resp.Body, airwallexMaxResponseSize))
  428. if err != nil {
  429. return nil, resp.StatusCode, err
  430. }
  431. return body, resp.StatusCode, nil
  432. }
  433. func airwallexProviderStatus(status string) string {
  434. switch strings.ToUpper(strings.TrimSpace(status)) {
  435. case airwallexPaymentStatusSucceeded:
  436. return payment.ProviderStatusPaid
  437. case airwallexPaymentStatusCancelled:
  438. return payment.ProviderStatusFailed
  439. default:
  440. return payment.ProviderStatusPending
  441. }
  442. }
  443. func airwallexRefundProviderStatus(status string) string {
  444. switch strings.ToUpper(strings.TrimSpace(status)) {
  445. case airwallexRefundStatusSettled:
  446. return payment.ProviderStatusSuccess
  447. case airwallexRefundStatusFailed:
  448. return payment.ProviderStatusFailed
  449. case airwallexRefundStatusReceived, airwallexRefundStatusAccepted:
  450. return payment.ProviderStatusPending
  451. default:
  452. return payment.ProviderStatusPending
  453. }
  454. }
  455. func airwallexDeterministicRequestID(parts ...string) string {
  456. hash := sha256.Sum256([]byte(strings.Join(parts, "\x00")))
  457. var id uuid.UUID
  458. copy(id[:], hash[:16])
  459. id[6] = (id[6] & 0x0f) | 0x40
  460. id[8] = (id[8] & 0x3f) | 0x80
  461. return id.String()
  462. }
  463. func verifyAirwallexWebhookSignature(rawBody string, headers map[string]string, secret string, now time.Time) error {
  464. secret = strings.TrimSpace(secret)
  465. if secret == "" {
  466. return fmt.Errorf("airwallex webhookSecret not configured")
  467. }
  468. timestamp := strings.TrimSpace(headers["x-timestamp"])
  469. signature := strings.ToLower(strings.TrimSpace(headers["x-signature"]))
  470. if timestamp == "" || signature == "" {
  471. return fmt.Errorf("airwallex notification missing x-timestamp or x-signature header")
  472. }
  473. mac := hmac.New(sha256.New, []byte(secret))
  474. _, _ = mac.Write([]byte(timestamp))
  475. _, _ = mac.Write([]byte(rawBody))
  476. expected := hex.EncodeToString(mac.Sum(nil))
  477. if !hmac.Equal([]byte(expected), []byte(signature)) {
  478. return fmt.Errorf("airwallex invalid signature")
  479. }
  480. ts, err := parseAirwallexWebhookTimestamp(timestamp)
  481. if err != nil {
  482. return err
  483. }
  484. if now.IsZero() {
  485. now = time.Now()
  486. }
  487. if diff := now.Sub(ts).Abs(); diff > airwallexWebhookTolerance {
  488. return fmt.Errorf("airwallex webhook timestamp outside tolerance")
  489. }
  490. return nil
  491. }
  492. func parseAirwallexWebhookTimestamp(raw string) (time.Time, error) {
  493. ts, err := decimal.NewFromString(strings.TrimSpace(raw))
  494. if err != nil {
  495. return time.Time{}, fmt.Errorf("airwallex invalid webhook timestamp")
  496. }
  497. millis := ts.IntPart()
  498. if millis <= 0 {
  499. return time.Time{}, fmt.Errorf("airwallex invalid webhook timestamp")
  500. }
  501. return time.UnixMilli(millis), nil
  502. }
  503. func parseAirwallexTime(raw string) (time.Time, error) {
  504. raw = strings.TrimSpace(raw)
  505. if raw == "" {
  506. return time.Time{}, fmt.Errorf("empty time")
  507. }
  508. for _, layout := range []string{time.RFC3339, "2006-01-02T15:04:05-0700", "2006-01-02T15:04:05.000-0700"} {
  509. if t, err := time.Parse(layout, raw); err == nil {
  510. return t, nil
  511. }
  512. }
  513. return time.Time{}, fmt.Errorf("invalid time: %s", raw)
  514. }
  515. func summarizeAirwallexResponse(body []byte) string {
  516. summary := strings.Join(strings.Fields(string(body)), " ")
  517. if summary == "" {
  518. return "<empty>"
  519. }
  520. if len(summary) > airwallexMaxErrorSummary {
  521. return summary[:airwallexMaxErrorSummary] + "..."
  522. }
  523. return summary
  524. }
  525. type airwallexAuthResponse struct {
  526. Token string `json:"token"`
  527. ExpiresAt string `json:"expires_at"`
  528. }
  529. type airwallexCreatePaymentIntentRequest struct {
  530. RequestID string `json:"request_id"`
  531. Amount airwallexRequestAmount `json:"amount"`
  532. Currency string `json:"currency"`
  533. MerchantOrderID string `json:"merchant_order_id"`
  534. ReturnURL string `json:"return_url,omitempty"`
  535. Descriptor string `json:"descriptor,omitempty"`
  536. Metadata map[string]string `json:"metadata,omitempty"`
  537. }
  538. type airwallexCreateRefundRequest struct {
  539. RequestID string `json:"request_id"`
  540. PaymentIntentID string `json:"payment_intent_id"`
  541. Amount airwallexRequestAmount `json:"amount,omitempty"`
  542. Reason string `json:"reason,omitempty"`
  543. }
  544. type airwallexRequestAmount struct {
  545. decimal.Decimal
  546. }
  547. func newAirwallexRequestAmount(amount decimal.Decimal) airwallexRequestAmount {
  548. return airwallexRequestAmount{Decimal: amount}
  549. }
  550. func (a airwallexRequestAmount) MarshalJSON() ([]byte, error) {
  551. return []byte(a.String()), nil
  552. }
  553. func (a *airwallexRequestAmount) UnmarshalJSON(data []byte) error {
  554. amount, err := decimal.NewFromString(strings.Trim(string(data), `"`))
  555. if err != nil {
  556. return err
  557. }
  558. a.Decimal = amount
  559. return nil
  560. }
  561. type airwallexPaymentIntent struct {
  562. ID string `json:"id"`
  563. RequestID string `json:"request_id"`
  564. ClientSecret string `json:"client_secret"`
  565. MerchantOrderID string `json:"merchant_order_id"`
  566. Amount decimal.Decimal `json:"amount"`
  567. Currency string `json:"currency"`
  568. Status string `json:"status"`
  569. Metadata map[string]string `json:"metadata"`
  570. }
  571. type airwallexRefund struct {
  572. ID string `json:"id"`
  573. RequestID string `json:"request_id"`
  574. PaymentIntentID string `json:"payment_intent_id"`
  575. Amount decimal.Decimal `json:"amount"`
  576. Currency string `json:"currency"`
  577. Status string `json:"status"`
  578. }
  579. type airwallexWebhookEvent struct {
  580. ID string `json:"id"`
  581. Name string `json:"name"`
  582. AccountID string `json:"accountId"`
  583. AccountIDSnake string `json:"account_id"`
  584. Data struct {
  585. Object json.RawMessage `json:"object"`
  586. } `json:"data"`
  587. }
  588. func (e airwallexWebhookEvent) accountID() string {
  589. if accountID := strings.TrimSpace(e.AccountID); accountID != "" {
  590. return accountID
  591. }
  592. return strings.TrimSpace(e.AccountIDSnake)
  593. }
  594. var (
  595. _ payment.Provider = (*Airwallex)(nil)
  596. _ payment.CancelableProvider = (*Airwallex)(nil)
  597. _ payment.MerchantIdentityProvider = (*Airwallex)(nil)
  598. )