From d5135cd1fe8f06edbbb7a556429c249063784148 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 14 Jun 2026 11:45:21 +0000 Subject: [PATCH] payments: accept CoinPay crypto_amount as number or string The live CoinPay API returns crypto_amount as a bare JSON number (e.g. 0.0031), but the struct decoded it as a string, so /payments/create failed with "cannot unmarshal number into ... crypto_amount of type string" and join@ showed "Payment is temporarily unavailable". The unit test had hidden the bug by sending the value quoted. Add a flexStr type that unmarshals from either a JSON number or string and use it for crypto_amount; update the test to send a number and add a direct flexStr decode test for both forms. Co-Authored-By: Claude Opus 4.8 --- internal/payments/payments.go | 38 ++++++++++++++++++++++++------ internal/payments/payments_test.go | 18 +++++++++++++- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/internal/payments/payments.go b/internal/payments/payments.go index 7003d7d..f05bae5 100644 --- a/internal/payments/payments.go +++ b/internal/payments/payments.go @@ -92,15 +92,39 @@ func Reference(plan, pubkeyFP string) string { return "abbs-" + plan + "-" + hex.EncodeToString(mac.Sum(nil))[:12] } +// flexStr decodes a JSON value that may arrive as either a string or a number +// into a string. CoinPay returns crypto_amount as a bare JSON number (e.g. +// 0.0031), but has sent it quoted in the past — accept both so a representation +// change on their side can't break the charge again. +type flexStr string + +func (f *flexStr) UnmarshalJSON(b []byte) error { + b = bytes.TrimSpace(b) + if len(b) == 0 || string(b) == "null" { + *f = "" + return nil + } + if b[0] == '"' { + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + *f = flexStr(s) + return nil + } + *f = flexStr(b) // number (or other scalar) — keep its literal text + return nil +} + // coinpayPayment is the (subset of the) CoinPay payment object, returned // wrapped as {"payment": {…}}. type coinpayPayment struct { - ID string `json:"id"` - Status string `json:"status"` - Address string `json:"payment_address"` - CryptoAmount string `json:"crypto_amount"` - CryptoCurr string `json:"crypto_currency"` - QR string `json:"qr_code"` + ID string `json:"id"` + Status string `json:"status"` + Address string `json:"payment_address"` + CryptoAmount flexStr `json:"crypto_amount"` + CryptoCurr string `json:"crypto_currency"` + QR string `json:"qr_code"` } type paymentEnvelope struct { @@ -174,7 +198,7 @@ func CreatePremiumCharge(ref string) (Charge, bool, error) { } return Charge{ Address: p.Address, - CryptoAmount: p.CryptoAmount, + CryptoAmount: string(p.CryptoAmount), Currency: p.CryptoCurr, FiatAmount: PremiumAmount(), FiatCurrency: PremiumCurrency(), diff --git a/internal/payments/payments_test.go b/internal/payments/payments_test.go index e2dd052..d377d61 100644 --- a/internal/payments/payments_test.go +++ b/internal/payments/payments_test.go @@ -20,7 +20,8 @@ func TestCreateAndVerifyPremium(t *testing.T) { _ = json.Unmarshal(b, &body) gotBlockchain, _ = body["blockchain"].(string) gotBusiness, _ = body["business_id"].(string) - _, _ = w.Write([]byte(`{"payment":{"id":"pay_1","payment_address":"0xABC","crypto_amount":"0.0031","crypto_currency":"ETH","status":"pending"}}`)) + // crypto_amount comes back as a bare JSON number from the live API. + _, _ = w.Write([]byte(`{"payment":{"id":"pay_1","payment_address":"0xABC","crypto_amount":0.0031,"crypto_currency":"ETH","status":"pending"}}`)) case r.Method == http.MethodGet && r.URL.Path == "/payments/pay_1": _, _ = w.Write([]byte(`{"payment":{"id":"pay_1","status":"confirmed"}}`)) default: @@ -76,3 +77,18 @@ func TestNotConfigured(t *testing.T) { t.Fatal("unconfigured verify must not be checked") } } + +func TestFlexStrDecodesNumberOrString(t *testing.T) { + for _, raw := range []string{ + `{"payment":{"crypto_amount":0.0031}}`, // live API: bare number + `{"payment":{"crypto_amount":"0.0031"}}`, // legacy: quoted string + } { + var env paymentEnvelope + if err := json.Unmarshal([]byte(raw), &env); err != nil { + t.Fatalf("unmarshal %s: %v", raw, err) + } + if got := string(env.Payment.CryptoAmount); got != "0.0031" { + t.Fatalf("crypto_amount from %s = %q, want 0.0031", raw, got) + } + } +}