feat(shipping): add Bring booking client and country dispatch for label purchase
Some checks failed
Deploy / deploy (push) Has been cancelled
Some checks failed
Deploy / deploy (push) Has been cancelled
This commit is contained in:
@@ -5,6 +5,8 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"terminalShop/pkg/bring"
|
||||
"terminalShop/pkg/shipping"
|
||||
"terminalShop/pkg/shippo"
|
||||
"time"
|
||||
|
||||
@@ -20,11 +22,19 @@ import (
|
||||
)
|
||||
|
||||
type OrderHandler struct {
|
||||
stripeKey string
|
||||
stripeKey string
|
||||
bringAPIUID string
|
||||
bringAPIKey string
|
||||
bringCustomerNumber string
|
||||
}
|
||||
|
||||
func NewOrderHandler(stripeSecretKey string) *OrderHandler {
|
||||
return &OrderHandler{stripeKey: stripeSecretKey}
|
||||
func NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber string) *OrderHandler {
|
||||
return &OrderHandler{
|
||||
stripeKey: stripeSecretKey,
|
||||
bringAPIUID: bringAPIUID,
|
||||
bringAPIKey: bringAPIKey,
|
||||
bringCustomerNumber: bringCustomerNumber,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -195,38 +205,52 @@ func (h *OrderHandler) PurchaseLabel(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Coffee.Ounces in ounces; shippo client expects kg
|
||||
const ozToKg = 0.0283495
|
||||
const defaultOunces = 12.0
|
||||
|
||||
lineItems := make([]shippo.LineItem, 0, len(order.Items))
|
||||
for _, it := range order.Items {
|
||||
oz := float64(it.Coffee.Ounces)
|
||||
totalKg := 0.0
|
||||
bringItems := make([]bring.LineItem, 0, len(order.Items))
|
||||
shippoItems := make([]shippo.LineItem, 0, len(order.Items))
|
||||
for _, item := range order.Items {
|
||||
oz := float64(item.Coffee.Ounces)
|
||||
if oz <= 0 {
|
||||
oz = defaultOunces
|
||||
}
|
||||
lineItems = append(lineItems, shippo.LineItem{
|
||||
Title: it.Name,
|
||||
Quantity: it.Quantity,
|
||||
WeightKg: oz * ozToKg,
|
||||
})
|
||||
kg := oz * ozToKg
|
||||
totalKg += float64(item.Quantity) * kg
|
||||
bringItems = append(bringItems, bring.LineItem{Title: item.Name, Quantity: item.Quantity, WeightKg: kg})
|
||||
shippoItems = append(shippoItems, shippo.LineItem{Title: item.Name, Quantity: item.Quantity, WeightKg: kg})
|
||||
}
|
||||
|
||||
to := shippo.Address{
|
||||
Name: order.ShippingName,
|
||||
Street1: order.ShippingStreet,
|
||||
Street2: order.ShippingStreet2,
|
||||
City: order.ShippingCity,
|
||||
State: order.ShippingState,
|
||||
Country: order.ShippingCountry,
|
||||
Zip: order.ShippingZip,
|
||||
Phone: order.ShippingPhone,
|
||||
}
|
||||
|
||||
client := shippo.NewClient(os.Getenv("SHIPPO_API_KEY"))
|
||||
result, err := client.CreateLabel(r.Context(), to, order.User.Email, lineItems)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusBadGateway, "SHIPPO_ERROR", err.Error(), nil)
|
||||
var result *shipping.LabelResult
|
||||
switch order.ShippingCountry {
|
||||
case "US":
|
||||
to := shippo.Address{
|
||||
Name: order.ShippingName, Street1: order.ShippingStreet, Street2: order.ShippingStreet2,
|
||||
City: order.ShippingCity, State: order.ShippingState, Country: order.ShippingCountry,
|
||||
Zip: order.ShippingZip, Phone: order.ShippingPhone,
|
||||
}
|
||||
client := shippo.NewClient(os.Getenv("SHIPPO_API_KEY"))
|
||||
result, err = client.CreateLabel(r.Context(), to, order.User.Email, shippoItems)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusBadGateway, "SHIPPO_ERROR", err.Error(), nil)
|
||||
return
|
||||
}
|
||||
case "NO":
|
||||
to := bring.BookingAddress{
|
||||
Name: order.ShippingName, Street1: order.ShippingStreet, Street2: order.ShippingStreet2,
|
||||
City: order.ShippingCity, Country: order.ShippingCountry, Zip: order.ShippingZip,
|
||||
Phone: order.ShippingPhone,
|
||||
}
|
||||
testMode := os.Getenv("ENVIRONMENT") != "production"
|
||||
client := bring.NewBookingClient(h.bringAPIUID, h.bringAPIKey, h.bringCustomerNumber, testMode)
|
||||
result, err = client.CreateLabel(r.Context(), to, order.User.Email, bringItems)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusBadGateway, "BRING_ERROR", err.Error(), nil)
|
||||
return
|
||||
}
|
||||
default:
|
||||
utils.RespondError(w, http.StatusBadRequest, "UNSUPPORTED_DESTINATION", "no label vendor configured for this country", nil)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ func main() {
|
||||
jwtManager := auth.NewJWTManager(cfg.JWTSecret, 30*time.Minute)
|
||||
|
||||
// Setup routes
|
||||
router := routes.SetupRoutes(version, cfg.StripeSecretKey, cfg.StripeWebhookSecret, jwtManager, cfg.AuthFingerprintKey, cfg.ShippoAPIKey, cfg.BringAPIUID, cfg.BringAPIKey, cfg.AppURL)
|
||||
router := routes.SetupRoutes(version, cfg.StripeSecretKey, cfg.StripeWebhookSecret, jwtManager, cfg.AuthFingerprintKey, cfg.ShippoAPIKey, cfg.BringAPIUID, cfg.BringAPIKey, cfg.BringCustomerNumber, cfg.AppURL)
|
||||
|
||||
// Create HTTP server
|
||||
server := &http.Server{
|
||||
|
||||
@@ -23,6 +23,7 @@ func SetupRoutes(
|
||||
shippoAPIKey string,
|
||||
bringAPIUID string,
|
||||
bringAPIKey string,
|
||||
bringCustomerNumber string,
|
||||
appURL string,
|
||||
) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
@@ -43,7 +44,7 @@ func SetupRoutes(
|
||||
authHandler := handlers.NewAuthHandler(jwtManager, authFingerprintKey)
|
||||
cartHandler := handlers.NewCartHandler(stripeSecretKey)
|
||||
cardHandler := handlers.NewCardHandler(stripeSecretKey, appURL)
|
||||
orderHandler := handlers.NewOrderHandler(stripeSecretKey)
|
||||
orderHandler := handlers.NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber)
|
||||
addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey)
|
||||
viewHandler := handlers.NewViewHandler()
|
||||
webhookHandler := handlers.NewWebhookHandler(stripeWebhookSecret, stripeSecretKey)
|
||||
|
||||
248
pkg/bring/booking.go
Normal file
248
pkg/bring/booking.go
Normal file
@@ -0,0 +1,248 @@
|
||||
package bring
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"terminalShop/pkg/shipping"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
)
|
||||
|
||||
const bookingBaseURL = "https://api.bring.com/booking-api/api/booking"
|
||||
|
||||
type BookingClient struct {
|
||||
apiUID string
|
||||
apiKey string
|
||||
customerNumber string
|
||||
testMode bool
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewBookingClient(apiUID string, apiKey string, customerNumber string, testMode bool) *BookingClient {
|
||||
return &BookingClient{
|
||||
apiUID: apiUID,
|
||||
apiKey: apiKey,
|
||||
customerNumber: customerNumber,
|
||||
testMode: testMode,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 20 * time.Second,
|
||||
Transport: otelhttp.NewTransport(http.DefaultTransport),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type BookingAddress struct {
|
||||
Name string
|
||||
Street1 string
|
||||
Street2 string
|
||||
City string
|
||||
Country string
|
||||
Zip string
|
||||
Phone string
|
||||
Email string
|
||||
ContactName string
|
||||
}
|
||||
|
||||
type LineItem struct {
|
||||
Title string
|
||||
Quantity int
|
||||
WeightKg float64
|
||||
}
|
||||
|
||||
func (c *BookingClient) CreateLabel(ctx context.Context, to BookingAddress, email string, items []LineItem) (*shipping.LabelResult, error) {
|
||||
from := fromAddressNO()
|
||||
if from.Street1 == "" {
|
||||
return nil, fmt.Errorf("SHIP_FROM_NO_STREET1 not configured")
|
||||
}
|
||||
if c.customerNumber == "" {
|
||||
return nil, fmt.Errorf("BRING_CUSTOMER_NUMBER not configured")
|
||||
}
|
||||
|
||||
totalKg := 0.0
|
||||
for _, item := range items {
|
||||
totalKg += float64(item.Quantity) * item.WeightKg
|
||||
}
|
||||
if totalKg <= 0 {
|
||||
return nil, fmt.Errorf("order has zero shipping weight")
|
||||
}
|
||||
|
||||
oslo, err := time.LoadLocation("Europe/Oslo")
|
||||
if err != nil {
|
||||
oslo = time.UTC
|
||||
}
|
||||
shippingAt := time.Now().In(oslo).Add(1 * time.Hour).Format("2006-01-02T15:04:05-07:00")
|
||||
|
||||
payload := map[string]any{
|
||||
"testIndicator": c.testMode,
|
||||
"schemaVersion": 1,
|
||||
"consignments": []map[string]any{{
|
||||
"shippingDateTime": shippingAt,
|
||||
"parties": map[string]any{
|
||||
"sender": partyPayload(from, ""),
|
||||
"recipient": partyPayload(to, email),
|
||||
},
|
||||
"product": map[string]any{
|
||||
"id": "SERVICEPAKKE",
|
||||
"customerNumber": c.customerNumber,
|
||||
},
|
||||
"packages": []map[string]any{{
|
||||
"weightInKg": roundN(totalKg, 3),
|
||||
"goodsDescription": "Roasted coffee beans",
|
||||
"dimensions": map[string]any{
|
||||
"heightInCm": 30,
|
||||
"widthInCm": 30,
|
||||
"lengthInCm": 30,
|
||||
},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
|
||||
var resp bookingResponse
|
||||
if err := c.doJSON(ctx, bookingBaseURL, payload, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp.Consignments) == 0 {
|
||||
return nil, fmt.Errorf("bring returned no consignments")
|
||||
}
|
||||
c0 := resp.Consignments[0]
|
||||
if c0.Confirmation == nil {
|
||||
return nil, fmt.Errorf("bring booking failed: %s", firstError(c0.Errors))
|
||||
}
|
||||
|
||||
confirmation := c0.Confirmation
|
||||
if len(confirmation.Packages) == 0 || confirmation.Packages[0].TrackingNumber == "" {
|
||||
return nil, fmt.Errorf("bring confirmation missing tracking number")
|
||||
}
|
||||
|
||||
labelURL := ""
|
||||
if confirmation.Links != nil {
|
||||
labelURL = confirmation.Links.Labels
|
||||
}
|
||||
|
||||
tracking := confirmation.Packages[0].TrackingNumber
|
||||
trackingURL := fmt.Sprintf("https://sporing.posten.no/sporing/%s", tracking)
|
||||
|
||||
return &shipping.LabelResult{
|
||||
TransactionID: confirmation.ConsignmentNumber,
|
||||
Carrier: "BRING",
|
||||
ServiceLevel: "SERVICEPAKKE",
|
||||
TrackingNumber: tracking,
|
||||
TrackingURL: trackingURL,
|
||||
LabelURL: labelURL,
|
||||
CostCents: 0,
|
||||
}, nil
|
||||
|
||||
}
|
||||
|
||||
type bookingResponse struct {
|
||||
Consignments []struct {
|
||||
Confirmation *struct {
|
||||
ConsignmentNumber string `json:"consignmentNumber"`
|
||||
Links *struct {
|
||||
Labels string `json:"labels"`
|
||||
} `json:"links"`
|
||||
Packages []struct {
|
||||
CorrelationID string `json:"correlationId"`
|
||||
TrackingNumber string `json:"trackingNumber"`
|
||||
} `json:"packages"`
|
||||
} `json:"confirmation"`
|
||||
Errors []struct {
|
||||
Messages []struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"messages"`
|
||||
} `json:"errors"`
|
||||
} `json:"consignments"`
|
||||
}
|
||||
|
||||
func firstError(errs []struct {
|
||||
Messages []struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"messages"`
|
||||
}) string {
|
||||
if len(errs) == 0 || len(errs[0].Messages) == 0 {
|
||||
return "no detail"
|
||||
}
|
||||
return errs[0].Messages[0].Message
|
||||
}
|
||||
|
||||
func (c *BookingClient) doJSON(ctx context.Context, url string, body any, out any) error {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return fmt.Errorf("new request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("X-Mybring-API-Uid", c.apiUID)
|
||||
req.Header.Set("X-Mybring-API-Key", c.apiKey)
|
||||
req.Header.Set("X-Mybring-API-Customer-Number", c.customerNumber)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("bring booking %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
if err := json.Unmarshal(respBody, out); err != nil {
|
||||
return fmt.Errorf("parse response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func roundN(v float64, n int) float64 {
|
||||
pow := 1.0
|
||||
for i := 0; i < n; i++ {
|
||||
pow *= 10
|
||||
}
|
||||
if v >= 0 {
|
||||
return float64(int64(v*pow+0.5)) / pow
|
||||
}
|
||||
return float64(int64(v*pow-0.5)) / pow
|
||||
}
|
||||
|
||||
func partyPayload(a BookingAddress, email string) map[string]any {
|
||||
m := map[string]any{
|
||||
"name": a.Name,
|
||||
"addressLine": a.Street1,
|
||||
"postalCode": a.Zip,
|
||||
"city": a.City,
|
||||
"countryCode": a.Country,
|
||||
}
|
||||
if a.Street2 != "" {
|
||||
m["addressLine2"] = a.Street2
|
||||
}
|
||||
if email != "" || a.Phone != "" {
|
||||
m["contact"] = map[string]any{"name": a.Name, "email": email, "phoneNumber": a.Phone}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func fromAddressNO() BookingAddress {
|
||||
return BookingAddress{
|
||||
Name: os.Getenv("SHIP_FROM_NO_NAME"),
|
||||
Street1: os.Getenv("SHIP_FROM_NO_STREET1"),
|
||||
Street2: os.Getenv("SHIP_FROM_NO_STREET2"),
|
||||
City: os.Getenv("SHIP_FROM_NO_CITY"),
|
||||
Country: os.Getenv("SHIP_FROM_NO_COUNTRY"),
|
||||
Zip: os.Getenv("SHIP_FROM_NO_ZIP"),
|
||||
Phone: os.Getenv("SHIP_FROM_NO_PHONE"),
|
||||
Email: os.Getenv("SHIP_FROM_NO_EMAIL"),
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ type Config struct {
|
||||
AuthFingerprintKey string
|
||||
BringAPIUID string
|
||||
BringAPIKey string
|
||||
BringCustomerNumber string
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables and validates required secrets.
|
||||
@@ -58,6 +59,7 @@ func Load() (*Config, error) {
|
||||
AuthFingerprintKey: os.Getenv("AUTH_FINGERPRINT_KEY"),
|
||||
BringAPIUID: os.Getenv("BRING_API_UID"),
|
||||
BringAPIKey: os.Getenv("BRING_API_KEY"),
|
||||
BringCustomerNumber: os.Getenv("BRING_CUSTOMER_NUMBER"),
|
||||
}
|
||||
|
||||
if err := cfg.validate(); err != nil {
|
||||
|
||||
12
pkg/shipping/types.go
Normal file
12
pkg/shipping/types.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package shipping
|
||||
|
||||
type LabelResult struct {
|
||||
TransactionID string
|
||||
RateID string
|
||||
Carrier string
|
||||
ServiceLevel string
|
||||
TrackingNumber string
|
||||
TrackingURL string
|
||||
LabelURL string
|
||||
CostCents int
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"terminalShop/pkg/shipping"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
@@ -52,17 +53,6 @@ type LineItem struct {
|
||||
WeightKg float64
|
||||
}
|
||||
|
||||
type LabelResult struct {
|
||||
TransactionID string
|
||||
RateID string
|
||||
Carrier string
|
||||
ServiceLevel string
|
||||
TrackingNumber string
|
||||
TrackingURL string
|
||||
LabelURL string
|
||||
CostCents int
|
||||
}
|
||||
|
||||
type shipmentRate struct {
|
||||
ObjectID string `json:"object_id"`
|
||||
Amount string `json:"amount"`
|
||||
@@ -95,7 +85,7 @@ type transactionResponse struct {
|
||||
} `json:"messages"`
|
||||
}
|
||||
|
||||
func (c *Client) CreateLabel(ctx context.Context, to Address, email string, items []LineItem) (*LabelResult, error) {
|
||||
func (c *Client) CreateLabel(ctx context.Context, to Address, email string, items []LineItem) (*shipping.LabelResult, error) {
|
||||
from := fromAddress()
|
||||
if from.Street1 == "" {
|
||||
return nil, fmt.Errorf("SHIP_FROM_STREET1 not configured")
|
||||
@@ -133,7 +123,7 @@ func (c *Client) CreateLabel(ctx context.Context, to Address, email string, item
|
||||
|
||||
costCents := parseAmountCents(rate.Amount)
|
||||
|
||||
return &LabelResult{
|
||||
return &shipping.LabelResult{
|
||||
TransactionID: final.ObjectID,
|
||||
RateID: rate.ObjectID,
|
||||
Carrier: rate.Provider,
|
||||
|
||||
Reference in New Issue
Block a user