diff --git a/api/handlers/orders.go b/api/handlers/orders.go index 0b019ba..e34001a 100644 --- a/api/handlers/orders.go +++ b/api/handlers/orders.go @@ -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 } diff --git a/api/main.go b/api/main.go index 361fac8..6d6f75f 100644 --- a/api/main.go +++ b/api/main.go @@ -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{ diff --git a/api/routes/routes.go b/api/routes/routes.go index 0b41aaa..6f121cf 100644 --- a/api/routes/routes.go +++ b/api/routes/routes.go @@ -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) diff --git a/pkg/bring/booking.go b/pkg/bring/booking.go new file mode 100644 index 0000000..4303d77 --- /dev/null +++ b/pkg/bring/booking.go @@ -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"), + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index c672df5..4384ae5 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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 { diff --git a/pkg/shipping/types.go b/pkg/shipping/types.go new file mode 100644 index 0000000..835ff99 --- /dev/null +++ b/pkg/shipping/types.go @@ -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 +} diff --git a/pkg/shippo/shippo.go b/pkg/shippo/shippo.go index e30257a..b224cd2 100644 --- a/pkg/shippo/shippo.go +++ b/pkg/shippo/shippo.go @@ -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,