diff --git a/fedex_registration.go b/fedex_registration.go new file mode 100644 index 0000000..fe52684 --- /dev/null +++ b/fedex_registration.go @@ -0,0 +1,159 @@ +package easypost + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/google/uuid" +) + +// FedExAccountValidationResponse represents the response from FedEx account validation endpoints. +type FedExAccountValidationResponse struct { + // If the response contains the following, one must complete pin or invoice validation next + EmailAddress string `json:"email_address,omitempty" url:"email_address,omitempty"` + Options []string `json:"options,omitempty" url:"options,omitempty"` + PhoneNumber string `json:"phone_number,omitempty" url:"phone_number,omitempty"` + + // If the response contains the following, pre-validation has been completed + ID string `json:"id,omitempty" url:"id,omitempty"` + Object string `json:"object,omitempty" url:"object,omitempty"` + Type string `json:"type,omitempty" url:"type,omitempty"` + Credentials map[string]string `json:"credentials,omitempty" url:"credentials,omitempty"` +} + +// FedExRequestPinResponse represents the response from requesting a PIN. +type FedExRequestPinResponse struct { + Message string `json:"message,omitempty" url:"message,omitempty"` +} + +// RegisterFedExAddress registers the billing address for a FedEx account. +// Advanced method for custom parameter structures. +func (c *Client) RegisterFedExAddress(fedexAccountNumber string, params map[string]interface{}) (out *FedExAccountValidationResponse, err error) { + return c.RegisterFedExAddressWithContext(context.Background(), fedexAccountNumber, params) +} + +// RegisterFedExAddressWithContext performs the same operation as RegisterFedExAddress, but allows specifying a context that can interrupt the request. +func (c *Client) RegisterFedExAddressWithContext(ctx context.Context, fedexAccountNumber string, params map[string]interface{}) (out *FedExAccountValidationResponse, err error) { + wrappedParams := wrapAddressValidation(params) + endpoint := fmt.Sprintf("fedex_registrations/%s/address", fedexAccountNumber) + err = c.do(ctx, http.MethodPost, endpoint, wrappedParams, &out) + return +} + +// RequestFedExPin requests a PIN for FedEx account verification. +func (c *Client) RequestFedExPin(fedexAccountNumber string, pinMethodOption string) (out *FedExRequestPinResponse, err error) { + return c.RequestFedExPinWithContext(context.Background(), fedexAccountNumber, pinMethodOption) +} + +// RequestFedExPinWithContext performs the same operation as RequestFedExPin, but allows specifying a context that can interrupt the request. +func (c *Client) RequestFedExPinWithContext(ctx context.Context, fedexAccountNumber string, pinMethodOption string) (out *FedExRequestPinResponse, err error) { + wrappedParams := map[string]interface{}{ + "pin_method": map[string]interface{}{ + "option": pinMethodOption, + }, + } + endpoint := fmt.Sprintf("fedex_registrations/%s/pin", fedexAccountNumber) + err = c.do(ctx, http.MethodPost, endpoint, wrappedParams, &out) + return +} + +// ValidateFedExPin validates the PIN entered by the user for FedEx account verification. +func (c *Client) ValidateFedExPin(fedexAccountNumber string, params map[string]interface{}) (out *FedExAccountValidationResponse, err error) { + return c.ValidateFedExPinWithContext(context.Background(), fedexAccountNumber, params) +} + +// ValidateFedExPinWithContext performs the same operation as ValidateFedExPin, but allows specifying a context that can interrupt the request. +func (c *Client) ValidateFedExPinWithContext(ctx context.Context, fedexAccountNumber string, params map[string]interface{}) (out *FedExAccountValidationResponse, err error) { + wrappedParams := wrapPinValidation(params) + endpoint := fmt.Sprintf("fedex_registrations/%s/pin/validate", fedexAccountNumber) + err = c.do(ctx, http.MethodPost, endpoint, wrappedParams, &out) + return +} + +// SubmitFedExInvoice submits invoice information to complete FedEx account registration. +func (c *Client) SubmitFedExInvoice(fedexAccountNumber string, params map[string]interface{}) (out *FedExAccountValidationResponse, err error) { + return c.SubmitFedExInvoiceWithContext(context.Background(), fedexAccountNumber, params) +} + +// SubmitFedExInvoiceWithContext performs the same operation as SubmitFedExInvoice, but allows specifying a context that can interrupt the request. +func (c *Client) SubmitFedExInvoiceWithContext(ctx context.Context, fedexAccountNumber string, params map[string]interface{}) (out *FedExAccountValidationResponse, err error) { + wrappedParams := wrapInvoiceValidation(params) + endpoint := fmt.Sprintf("fedex_registrations/%s/invoice", fedexAccountNumber) + err = c.do(ctx, http.MethodPost, endpoint, wrappedParams, &out) + return +} + +// wrapAddressValidation wraps address validation parameters and ensures the "name" field exists. +// If not present, generates a UUID (with hyphens removed) as the name. +func wrapAddressValidation(params map[string]interface{}) map[string]interface{} { + wrappedParams := make(map[string]interface{}) + + if addressValidation, ok := params["address_validation"].(map[string]interface{}); ok { + addressValidationCopy := make(map[string]interface{}) + for k, v := range addressValidation { + addressValidationCopy[k] = v + } + ensureNameField(addressValidationCopy) + wrappedParams["address_validation"] = addressValidationCopy + } + + if easypostDetails, ok := params["easypost_details"]; ok { + wrappedParams["easypost_details"] = easypostDetails + } + + return wrappedParams +} + +// wrapPinValidation wraps PIN validation parameters and ensures the "name" field exists. +// If not present, generates a UUID (with hyphens removed) as the name. +func wrapPinValidation(params map[string]interface{}) map[string]interface{} { + wrappedParams := make(map[string]interface{}) + + if pinValidation, ok := params["pin_validation"].(map[string]interface{}); ok { + pinValidationCopy := make(map[string]interface{}) + for k, v := range pinValidation { + pinValidationCopy[k] = v + } + ensureNameField(pinValidationCopy) + wrappedParams["pin_validation"] = pinValidationCopy + } + + if easypostDetails, ok := params["easypost_details"]; ok { + wrappedParams["easypost_details"] = easypostDetails + } + + return wrappedParams +} + +// wrapInvoiceValidation wraps invoice validation parameters and ensures the "name" field exists. +// If not present, generates a UUID (with hyphens removed) as the name. +func wrapInvoiceValidation(params map[string]interface{}) map[string]interface{} { + wrappedParams := make(map[string]interface{}) + + if invoiceValidation, ok := params["invoice_validation"].(map[string]interface{}); ok { + invoiceValidationCopy := make(map[string]interface{}) + for k, v := range invoiceValidation { + invoiceValidationCopy[k] = v + } + ensureNameField(invoiceValidationCopy) + wrappedParams["invoice_validation"] = invoiceValidationCopy + } + + if easypostDetails, ok := params["easypost_details"]; ok { + wrappedParams["easypost_details"] = easypostDetails + } + + return wrappedParams +} + +// ensureNameField ensures the "name" field exists in the provided map. +// If not present, generates a UUID (with hyphens removed) as the name. +// This follows the pattern used in the web UI implementation. +func ensureNameField(m map[string]interface{}) { + if _, exists := m["name"]; !exists || m["name"] == nil { + uuidStr := strings.ReplaceAll(uuid.New().String(), "-", "") + m["name"] = uuidStr + } +} diff --git a/fedex_registration_test.go b/fedex_registration_test.go new file mode 100644 index 0000000..23fdeb1 --- /dev/null +++ b/fedex_registration_test.go @@ -0,0 +1,156 @@ +package easypost + +func GetFedExRegistrationMockRequests() []MockRequest { + return []MockRequest{ + { + MatchRule: MockRequestMatchRule{ + Method: "POST", + UrlRegexPattern: "v2\\/fedex_registrations\\/\\S*\\/address$", + }, + ResponseInfo: MockRequestResponseInfo{ + StatusCode: 200, + Body: `{"email_address":null,"options":["SMS","CALL","INVOICE"],"phone_number":"***-***-9721"}`, + }, + }, + { + MatchRule: MockRequestMatchRule{ + Method: "POST", + UrlRegexPattern: "v2\\/fedex_registrations\\/\\S*\\/pin$", + }, + ResponseInfo: MockRequestResponseInfo{ + StatusCode: 200, + Body: `{"message":"sent secured Pin"}`, + }, + }, + { + MatchRule: MockRequestMatchRule{ + Method: "POST", + UrlRegexPattern: "v2\\/fedex_registrations\\/\\S*\\/pin\\/validate$", + }, + ResponseInfo: MockRequestResponseInfo{ + StatusCode: 200, + Body: `{"id":"ca_123","object":"CarrierAccount","type":"FedexAccount","credentials":{"account_number":"123456789","mfa_key":"123456789-XXXXX"}}`, + }, + }, + { + MatchRule: MockRequestMatchRule{ + Method: "POST", + UrlRegexPattern: "v2\\/fedex_registrations\\/\\S*\\/invoice$", + }, + ResponseInfo: MockRequestResponseInfo{ + StatusCode: 200, + Body: `{"id":"ca_123","object":"CarrierAccount","type":"FedexAccount","credentials":{"account_number":"123456789","mfa_key":"123456789-XXXXX"}}`, + }, + }, + } +} + +func (c *ClientTests) TestRegisterFedExAddress() { + mockRequests := GetFedExRegistrationMockRequests() + client := c.MockClient(mockRequests) + assert, require := c.Assert(), c.Require() + + fedexAccountNumber := "123456789" + addressValidation := map[string]interface{}{ + "name": "BILLING NAME", + "street1": "1234 BILLING STREET", + "city": "BILLINGCITY", + "state": "ST", + "postal_code": "12345", + "country_code": "US", + } + + easypostDetails := map[string]interface{}{ + "carrier_account_id": "ca_123", + } + + params := map[string]interface{}{ + "address_validation": addressValidation, + "easypost_details": easypostDetails, + } + + response, err := client.RegisterFedExAddress(fedexAccountNumber, params) + require.NoError(err) + + assert.Equal("", response.EmailAddress) + assert.Contains(response.Options, "SMS") + assert.Contains(response.Options, "CALL") + assert.Contains(response.Options, "INVOICE") + assert.Equal("***-***-9721", response.PhoneNumber) +} + +func (c *ClientTests) TestRequestFedExPin() { + mockRequests := GetFedExRegistrationMockRequests() + client := c.MockClient(mockRequests) + assert, require := c.Assert(), c.Require() + + fedexAccountNumber := "123456789" + + response, err := client.RequestFedExPin(fedexAccountNumber, "SMS") + require.NoError(err) + + assert.Equal("sent secured Pin", response.Message) +} + +func (c *ClientTests) TestValidateFedExPin() { + mockRequests := GetFedExRegistrationMockRequests() + client := c.MockClient(mockRequests) + assert, require := c.Assert(), c.Require() + + fedexAccountNumber := "123456789" + pinValidation := map[string]interface{}{ + "pin_code": "123456", + "name": "BILLING NAME", + } + + easypostDetails := map[string]interface{}{ + "carrier_account_id": "ca_123", + } + + params := map[string]interface{}{ + "pin_validation": pinValidation, + "easypost_details": easypostDetails, + } + + response, err := client.ValidateFedExPin(fedexAccountNumber, params) + require.NoError(err) + + assert.Equal("ca_123", response.ID) + assert.Equal("CarrierAccount", response.Object) + assert.Equal("FedexAccount", response.Type) + assert.Equal("123456789", response.Credentials["account_number"]) + assert.Equal("123456789-XXXXX", response.Credentials["mfa_key"]) +} + +func (c *ClientTests) TestSubmitFedExInvoice() { + mockRequests := GetFedExRegistrationMockRequests() + client := c.MockClient(mockRequests) + assert, require := c.Assert(), c.Require() + + fedexAccountNumber := "123456789" + invoiceValidation := map[string]interface{}{ + "name": "BILLING NAME", + "invoice_number": "INV-12345", + "invoice_date": "2025-12-08", + "invoice_amount": "100.00", + "invoice_currency": "USD", + } + + easypostDetails := map[string]interface{}{ + "carrier_account_id": "ca_123", + } + + params := map[string]interface{}{ + "invoice_validation": invoiceValidation, + "easypost_details": easypostDetails, + } + + response, err := client.SubmitFedExInvoice(fedexAccountNumber, params) + require.NoError(err) + + assert.Equal("ca_123", response.ID) + assert.Equal("CarrierAccount", response.Object) + assert.Equal("FedexAccount", response.Type) + assert.Equal("123456789", response.Credentials["account_number"]) + assert.Equal("123456789-XXXXX", response.Credentials["mfa_key"]) +} diff --git a/justfile b/justfile index 1265af2..7d13c0e 100644 --- a/justfile +++ b/justfile @@ -5,7 +5,7 @@ build: # Clean the project clean: rm -rf dist - rm $(go env GOPATH)/bin/easypost-go + rm -f $(go env GOPATH)/bin/easypost-go # Get test coverage and open it in a browser coverage: @@ -22,7 +22,7 @@ init-examples-submodule: # Install and vendor dependencies install: init-examples-submodule curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.64.6 - curl -sSfL https://raw.githubusercontent.com/securego/gosec/master/install.sh | sh -s -- -b $(go env GOPATH)/bin + curl -sSfL https://raw.githubusercontent.com/securego/gosec/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.22.11 go mod vendor # Lint the project