diff options
| author | Sam Anthony <sam@samanthony.xyz> | 2026-05-11 16:16:24 -0400 |
|---|---|---|
| committer | Sam Anthony <sam@samanthony.xyz> | 2026-05-11 16:16:24 -0400 |
| commit | af2de318402df1fd8d33192d71613c21c4ee96bf (patch) | |
| tree | 473c33981032bc73f5a5f4282ef8b1292333ce28 | |
| parent | fcfe73600c8548d45820176d58f1c8cfc2327810 (diff) | |
| download | lulu-af2de318402df1fd8d33192d71613c21c4ee96bf.zip | |
implement POST /print-job-cost-calculations
| -rw-r--r-- | .gitignore | 4 | ||||
| -rw-r--r-- | Makefile | 3 | ||||
| -rw-r--r-- | cost.go | 134 | ||||
| -rw-r--r-- | cost_test.go | 417 | ||||
| -rw-r--r-- | err.go | 2 | ||||
| -rw-r--r-- | go.mod | 7 | ||||
| -rw-r--r-- | go.sum | 14 | ||||
| -rw-r--r-- | lulu.go | 50 | ||||
| -rw-r--r-- | lulu_test.go | 2 | ||||
| -rw-r--r-- | notes | 11 | ||||
| -rw-r--r-- | ship.go | 119 | ||||
| -rw-r--r-- | testdata/printjobcostreq.json | 23 | ||||
| -rw-r--r-- | testdata/printjobcostresp.json | 95 |
13 files changed, 539 insertions, 342 deletions
@@ -1,3 +1,5 @@ *_gen.go -testdata +spec.yml +testdata/clientkey +testdata/clientsecret todo @@ -8,6 +8,9 @@ build: ${SRC} ${GEN} ${GEN}: ${SRC} go generate +spec.yml: + curl -L -o $@ 'https://api.lulu.com/api-docs/openapi-specs/openapi_public.yml' + clean: rm -f ${GEN} @@ -1,38 +1,42 @@ package lulu -// TODO: should we remove the redundant tax/subtotal fields or remain -// true to their retarded api? - import ( "encoding/json" - "fmt" "github.com/shopspring/decimal" ) -// printJobCostReq is the json body of a /print-job-cost-calculations/ request. type printJobCostReq struct { - LineItems []printJobCostLineItem `json:"line_items"` - ShipAddr ShippingAddress `json:"shipping_address"` - ShipOpt ShippingLevel `json:"shipping_option"` + LineItems []PrintJobCostLineItem `json:"line_items"` + ShipAddr printJobCostReqShipAddr `json:"shipping_address"` + ShipOpt ShippingLevel `json:"shipping_option"` } -type printJobCostLineItem struct { +type PrintJobCostLineItem struct { NPages uint `json:"page_count"` Mfg PkgId `json:"pod_package_id"` Quantity uint `json:"quantity"` } +type printJobCostReqShipAddr struct { + City string `json:"city"` + Country string `json:"country_code"` + PostCode string `json:"postcode"` + State string `json:"state_code"` + Street1 string `json:"street1"` + Phone string `json:"phone_number"` +} + // PrintJobCost is the response from /print-job-cost-calculations/. type PrintJobCost struct { Addr, SuggestedAddr ShippingAddress - AddrWarning ShippingAddressWarning - Currency string + AddrWarnings []ShippingAddressWarning Fees []Fee - FulfillmentCost FulfillmentCost LineItemCosts []LineItemCost ShipCost FulfillmentCost - TotalCostExclTax, TotalCostInclTax, TotalDiscount, TotalTax decimal.Decimal + FulfillmentCost FulfillmentCost + TotalTax, TotalCostExclTax, TotalCostInclTax, TotalDiscount decimal.Decimal + Currency string } type ShippingAddressWarning struct { @@ -53,113 +57,67 @@ type Fee struct { } type FulfillmentCost struct { - TaxRate decimal.Decimal `json:"tax_rate"` TotalCostExclTax decimal.Decimal `json:"total_cost_excl_tax"` TotalCostInclTax decimal.Decimal `json:"total_cost_incl_tax"` TotalTax decimal.Decimal `json:"total_tax"` + TaxRate decimal.Decimal `json:"tax_rate"` } type LineItemCost struct { - CostExclDiscounts decimal.Decimal `json:"cost_excl_discounts"` - Discounts []Discount `json:"discounts"` - Quantity uint `json:"quantity"` - TaxRate decimal.Decimal `json:"tax_rate"` - TotalCostExclDiscounts decimal.Decimal `json:"total_cost_excl_discounts"` - TotalCostExclTax decimal.Decimal `json:"total_cost_excl_tax"` - TotalCostInclTax decimal.Decimal `json:"total_cost_incl_tax"` - TotalTax decimal.Decimal `json:"total_tax"` - UnitTierCost *decimal.Decimal `json:"unit_tier_cost"` + CostExclDiscounts decimal.Decimal `json:"cost_excl_discounts"` + TotalTax decimal.Decimal `json:"total_tax"` + TaxRate decimal.Decimal `json:"tax_rate"` + Quantity uint `json:"quantity"` + TotalCostExclTax decimal.Decimal `json:"total_cost_excl_tax"` + TotalCostExclDiscounts decimal.Decimal `json:"total_cost_excl_discounts"` + TotalCostInclTax decimal.Decimal `json:"total_cost_incl_tax"` + Discounts []Discount `json:"discounts"` + UnitTierCost decimal.Decimal `json:"unit_tier_cost"` } type Discount struct { - Amount decimal.Decimal `json:"amount"` - Descr string `json:"description"` + Amount decimal.Decimal `json:"amount"` + Description string `json:"description"` } -// printJobCostResp is the json body of a /print-job-cost-calculations/ response. type printJobCostResp struct { - Addr struct { - City string `json:"city"` - CountryCode string `json:"country_code"` - IsBusiness bool `json:"is_business"` - Name string `json:"name"` - Phone string `json:"phone_number"` - PostCode string `json:"postcode"` - StateCode string `json:"state_code"` - Street1 string `json:"street1"` - Street2 string `json:"street2"` - Warning ShippingAddressWarning `json:"warnings"` - Suggested suggestedShippingAddress `json:"suggested_address"` - } `json:"shipping_address"` - Currency string `json:"currency"` + Addr json.RawMessage `json:"shipping_address"` Fees []Fee `json:"fees"` - FulfillmentCost FulfillmentCost `json:"fulfillment_cost"` LineItemCosts []LineItemCost `json:"line_item_costs"` ShipCost FulfillmentCost `json:"shipping_cost"` + FulfillmentCost FulfillmentCost `json:"fulfillment_cost"` + TotalTax decimal.Decimal `json:"total_tax"` TotalCostExclTax decimal.Decimal `json:"total_cost_excl_tax"` TotalCostInclTax decimal.Decimal `json:"total_cost_incl_tax"` TotalDiscount decimal.Decimal `json:"total_discount_amount"` - TotalTax decimal.Decimal `json:"total_tax"` -} - -// Some blockhead that Lulu hired thought it would be a good idea to use -// a string in one place and a number in another for the postal code. -// That's why this special struct is needed. -type suggestedShippingAddress struct { - CountryCode string `json:"country_code"` - StateCode string `json:"state_code"` - PostCode uint `json:"postcode"` - City string `json:"city"` - Street1 string `json:"street1"` - Street2 string `json:"street2"` + Currency string `json:"currency"` } func (c *PrintJobCost) UnmarshalJSON(data []byte) error { - // Lulu decided to put warnings and suggested_address INSIDE - // shipping_address in the response. So we unmarshal into an - // alias struct and then map onto our own structs. The - // alternative is to add everything to our ShippingAddress - // struct, but that would be confusing to users of this library: - // ShippingAddress would have semantics because some fields would - // be "optional". - var resp printJobCostResp if err := json.Unmarshal(data, &resp); err != nil { return err } - - addr := resp.Addr - c.Addr = ShippingAddress{ - City: addr.City, - CountryCode: addr.CountryCode, - IsBusiness: addr.IsBusiness, - Name: addr.Name, - Phone: addr.Phone, - PostCode: addr.PostCode, - StateCode: addr.StateCode, - Street1: addr.Street1, - Street2: addr.Street2, + if err := json.Unmarshal(resp.Addr, &c.Addr); err != nil { + return err } - sugad := addr.Suggested - c.SuggestedAddr = ShippingAddress{ - CountryCode: sugad.CountryCode, - StateCode: sugad.StateCode, - PostCode: fmt.Sprint(sugad.PostCode), // idiot - City: sugad.City, - Street1: sugad.Street1, - Street2: sugad.Street2, + var warnsAndSugg struct { + Warnings []ShippingAddressWarning `json:"warnings"` + Suggested ShippingAddress `json:"suggested_address"` } - c.AddrWarning = addr.Warning - - c.Currency = resp.Currency + if err := json.Unmarshal(resp.Addr, &warnsAndSugg); err != nil { + return err + } + c.SuggestedAddr = warnsAndSugg.Suggested + c.AddrWarnings = warnsAndSugg.Warnings c.Fees = resp.Fees - c.FulfillmentCost = resp.FulfillmentCost c.LineItemCosts = resp.LineItemCosts c.ShipCost = resp.ShipCost + c.FulfillmentCost = resp.FulfillmentCost + c.TotalTax = resp.TotalTax c.TotalCostExclTax = resp.TotalCostExclTax c.TotalCostInclTax = resp.TotalCostInclTax c.TotalDiscount = resp.TotalDiscount - c.TotalTax = resp.TotalTax - + c.Currency = resp.Currency return nil } diff --git a/cost_test.go b/cost_test.go index a8aeb3d..1abe63c 100644 --- a/cost_test.go +++ b/cost_test.go @@ -1,247 +1,208 @@ package lulu import ( + _ "embed" "testing" "github.com/shopspring/decimal" + "github.com/stretchr/testify/require" + "golang.org/x/text/currency" ) -func TestMarshalUnmarshalPrintJobCostReq(t *testing.T) { - j := `{ - "line_items": [ - { - "page_count": 32, - "pod_package_id": "0600X0900.BW.STD.PB.060UW444.MXX", - "quantity": 20 - }, { - "page_count": 324, - "pod_package_id": "0425X0687.BW.STD.PB.060UW444.GXX", - "quantity": 200 - } - ], - "shipping_address": { - "city": "Lübeck", - "country_code": "DE", - "postcode": "23552", - "state_code": "", - "street1": "Holstenstr. 40", - "phone_number": "844-212-0689", - "name": "", - "street2": "", - "is_business": false - - }, - "shipping_option": "EXPRESS" - }` - addr := printJobCostReq{ - []printJobCostLineItem{ - { - 32, - PkgId{UsTrade, Mono, Standard, Perfect, P60UncoatedWhite, Matte, NoLinen, NoFoil}, - 20, - }, { - 324, - PkgId{Pocketbook, Mono, Standard, Perfect, P60UncoatedWhite, Gloss, NoLinen, NoFoil}, - 200, - }, - }, - ShippingAddress{ - City: "Lübeck", - CountryCode: "DE", - PostCode: "23552", - Street1: "Holstenstr. 40", - Phone: "844-212-0689", - // TODO: does the API mind the extra fields? - }, - Express, - } - requireMarshalJsonEq(t, j, addr) - requireUnmarshalJsonEq(t, addr, j) -} +var ( + //go:embed testdata/printjobcostreq.json + printJobCostReqJson string -func TestUnmarshalPrintJobCost(t *testing.T) { - j := `{ - "shipping_address": { - "city": "Lübeck", - "country_code": "DE", - "is_business": false, - "name": "Hans Dampf", - "phone_number": "844-212-0689", - "postcode": "23552", - "state_code": "", - "street1": "Holstenstr. 40", - "street2": "", - "warnings": { - "type": "validation_warning", - "path": "external", - "code": "REPLACED", - "message": "street1: Holstenstr. 40 -> Holstenstraße 40" - }, - "suggested_address": { - "country_code": "DE", - "state_code": null, - "postcode": 23552, - "city": "Lübeck", - "street1": "Holstenstraße 40\"", - "street2": null - } - }, - "currency": "USD", - "fees": [ - { - "currency": "USD", - "fee_type": "HANDLING_FEE", - "sku": "HANDLING_FEE", - "tax_rate": "0.060000", - "total_cost_excl_tax": "14.00", - "total_cost_incl_tax": "14.84", - "total_tax": "0.84" - }, - { - "currency": "USD", - "fee_type": "FULFILLMENT_FEE", - "sku": "FULFILLMENT_FEE", - "tax_rate": "0.060000", - "total_cost_excl_tax": "0.75", - "total_cost_incl_tax": "0.80", - "total_tax": "0.05" - } - ], - "fulfillment_cost": { - "tax_rate": "0.06", - "total_cost_excl_tax": "0.75", - "total_cost_incl_tax": "0.80", - "total_tax": "0.05" - }, - "line_item_costs": [ - { - "cost_excl_discounts": "2.55", - "discounts": [], - "quantity": 20, - "tax_rate": "0.060000", - "total_cost_excl_discounts": "51.00", - "total_cost_excl_tax": "51.00", - "total_cost_incl_tax": "54.06", - "total_tax": "3.06", - "unit_tier_cost": null - }, - { - "cost_excl_discounts": "9.26", - "discounts": [ - { - "amount": "92.60", - "description": "Volume Discount 5%" - } - ], - "quantity": 200, - "tax_rate": "0.060000", - "total_cost_excl_discounts": "1852.00", - "total_cost_excl_tax": "1759.40", - "total_cost_incl_tax": "1864.96", - "total_tax": "105.56", - "unit_tier_cost": null - } - ], - "shipping_cost": { - "tax_rate": "0.06", - "total_cost_excl_tax": "318.44", - "total_cost_incl_tax": "337.55", - "total_tax": "19.11" - }, - "total_cost_excl_tax": "2129.59", - "total_cost_incl_tax": "2257.37", - "total_discount_amount": "92.60", - "total_tax": "127.78" - }` + //go:embed testdata/printjobcostresp.json + printJobCostRespJson string +) - cost := PrintJobCost{ - Addr: ShippingAddress{ - City: "Lübeck", - CountryCode: "DE", - IsBusiness: false, - Name: "Hans Dampf", - Phone: "844-212-0689", - PostCode: "23552", - StateCode: "", - Street1: "Holstenstr. 40", - Street2: "", - }, - SuggestedAddr: ShippingAddress{ - CountryCode: "DE", - StateCode: "", - PostCode: "23552", - City: "Lübeck", - Street1: "Holstenstraße 40\"", - Street2: "", +var printJobCostReqSample = printJobCostReq{ + []PrintJobCostLineItem{ + { + 32, + PkgId{UsTrade, Mono, Standard, Perfect, P60UncoatedWhite, Matte, NoLinen, NoFoil}, + 20, + }, { + 324, + PkgId{Pocketbook, Mono, Standard, Perfect, P60UncoatedWhite, Gloss, NoLinen, NoFoil}, + 200, }, - AddrWarning: ShippingAddressWarning{ - "validation_warning", - "external", - "REPLACED", - "street1: Holstenstr. 40 -> Holstenstraße 40", - }, - Currency: "USD", - Fees: []Fee{ - { - Currency: "USD", - Type: "HANDLING_FEE", - Sku: "HANDLING_FEE", - TaxRate: decimal.RequireFromString("0.060000"), - TotalCostExclTax: decimal.RequireFromString("14.00"), - TotalCostInclTax: decimal.RequireFromString("14.84"), - TotalTax: decimal.RequireFromString("0.84"), - }, { - Currency: "USD", - Type: "FULFILLMENT_FEE", - Sku: "FULFILLMENT_FEE", - TaxRate: decimal.RequireFromString("0.060000"), - TotalCostExclTax: decimal.RequireFromString("0.75"), - TotalCostInclTax: decimal.RequireFromString("0.80"), - TotalTax: decimal.RequireFromString("0.05"), - }, - }, - FulfillmentCost: FulfillmentCost{ - TaxRate: decimal.RequireFromString("0.06"), + }, + printJobCostReqShipAddr{ + City: "Lübeck", + Country: "DE", + PostCode: "23552", + State: "", + Street1: "Holstenstr. 40", + Phone: "844-212-0689", + }, + Express, +} + +var printJobCostSample = PrintJobCost{ + Addr: ShippingAddress{ + City: "Lübeck", + PostCode: "23552", + Street1: "Holstenstr. 40", + Phone: "844-212-0689", + State: "", + Country: "DE", + IsBusiness: false, + Name: ". .", + }, + SuggestedAddr: ShippingAddress{ + Country: "DE", + State: "", + PostCode: "23552", + City: "Lübeck", + Street1: "Holstenstraße 40", + Street2: "", + }, + AddrWarnings: []ShippingAddressWarning{{ + "validation_warning", + "external", + "REPLACED", + "street1: Holstenstr. 40 -> Holstenstraße 40", + }}, + Fees: []Fee{ + { + Currency: "USD", + Type: "HANDLING_FEE", + Sku: "HANDLING_FEE", + TaxRate: decimal.RequireFromString("0.060000"), + TotalCostExclTax: decimal.RequireFromString("14.00"), + TotalCostInclTax: decimal.RequireFromString("14.84"), + TotalTax: decimal.RequireFromString("0.84"), + }, { + Currency: "USD", + Type: "FULFILLMENT_FEE", + Sku: "FULFILLMENT_FEE", + TaxRate: decimal.RequireFromString("0.060000"), TotalCostExclTax: decimal.RequireFromString("0.75"), TotalCostInclTax: decimal.RequireFromString("0.80"), TotalTax: decimal.RequireFromString("0.05"), }, - LineItemCosts: []LineItemCost{ - { - CostExclDiscounts: decimal.RequireFromString("2.55"), - Discounts: []Discount{}, - Quantity: 20, - TaxRate: decimal.RequireFromString("0.060000"), - TotalCostExclDiscounts: decimal.RequireFromString("51.00"), - TotalCostExclTax: decimal.RequireFromString("51.00"), - TotalCostInclTax: decimal.RequireFromString("54.06"), - TotalTax: decimal.RequireFromString("3.06"), - UnitTierCost: nil, - }, { - CostExclDiscounts: decimal.RequireFromString("9.26"), - Discounts: []Discount{ - {decimal.RequireFromString("92.60"), "Volume Discount 5%"}, - }, - Quantity: 200, - TaxRate: decimal.RequireFromString("0.060000"), - TotalCostExclDiscounts: decimal.RequireFromString("1852.00"), - TotalCostExclTax: decimal.RequireFromString("1759.40"), - TotalCostInclTax: decimal.RequireFromString("1864.96"), - TotalTax: decimal.RequireFromString("105.56"), - UnitTierCost: nil, - }, - }, - ShipCost: FulfillmentCost{ - TaxRate: decimal.RequireFromString("0.06"), - TotalCostExclTax: decimal.RequireFromString("318.44"), - TotalCostInclTax: decimal.RequireFromString("337.55"), - TotalTax: decimal.RequireFromString("19.11"), + }, + LineItemCosts: []LineItemCost{ + { + CostExclDiscounts: decimal.RequireFromString("4.95"), + TotalTax: decimal.RequireFromString("6.93"), + TaxRate: decimal.RequireFromString("0.070000"), + Quantity: 20, + TotalCostExclTax: decimal.RequireFromString("99.00"), + TotalCostExclDiscounts: decimal.RequireFromString("99.00"), + TotalCostInclTax: decimal.RequireFromString("105.93"), + Discounts: []Discount{}, + UnitTierCost: decimal.RequireFromString("4.95"), + }, { + CostExclDiscounts: decimal.RequireFromString("16.19"), + TotalTax: decimal.RequireFromString("215.33"), + TaxRate: decimal.RequireFromString("0.070000"), + Quantity: 200, + TotalCostExclTax: decimal.RequireFromString("3076.10"), + TotalCostExclDiscounts: decimal.RequireFromString("3238.00"), + TotalCostInclTax: decimal.RequireFromString("3291.43"), + Discounts: []Discount{{decimal.RequireFromString("161.90"), "Volume Discount 5%"}}, + UnitTierCost: decimal.RequireFromString("16.19"), }, - TotalCostExclTax: decimal.RequireFromString("2129.59"), - TotalCostInclTax: decimal.RequireFromString("2257.37"), - TotalDiscount: decimal.RequireFromString("92.60"), - TotalTax: decimal.RequireFromString("127.78"), + }, + ShipCost: FulfillmentCost{ + TotalCostExclTax: decimal.RequireFromString("1085.50"), + TotalCostInclTax: decimal.RequireFromString("1161.49"), + TotalTax: decimal.RequireFromString("75.99"), + TaxRate: decimal.RequireFromString("0.19"), + }, + FulfillmentCost: FulfillmentCost{ + TotalCostExclTax: decimal.RequireFromString("1.05"), + TotalCostInclTax: decimal.RequireFromString("1.12"), + TotalTax: decimal.RequireFromString("0.07"), + TaxRate: decimal.RequireFromString("0.19"), + }, + TotalTax: decimal.RequireFromString("298.32"), + TotalCostExclTax: decimal.RequireFromString("4261.65"), + TotalCostInclTax: decimal.RequireFromString("4559.97"), + TotalDiscount: decimal.RequireFromString("161.90"), + Currency: "CAD", +} + +func TestMarshalPrintJobCostReq(t *testing.T) { + requireMarshalJsonEq(t, printJobCostReqJson, printJobCostReqSample) +} + +func TestUnmarshalPrintJobCost(t *testing.T) { + requireUnmarshalJsonEq(t, printJobCostSample, printJobCostRespJson) +} + +func TestPrintJobCost(t *testing.T) { + c := newClient(t) + items := printJobCostReqSample.LineItems + addr := ShippingAddress{ + City: "Lübeck", + Country: "DE", + PostCode: "23552", + State: "", + Street1: "Holstenstr. 40", + Phone: "844-212-0689", + } + shiplvl := printJobCostReqSample.ShipOpt + cost, err := c.PrintJobCost(items, addr, shiplvl) + require.NoError(t, err) + require.Equal(t, "Lübeck", cost.Addr.City) + require.Equal(t, "23552", cost.Addr.PostCode) + require.Equal(t, "Holstenstr. 40", cost.Addr.Street1) + require.Equal(t, "844-212-0689", cost.Addr.Phone) + require.Empty(t, cost.Addr.State) + require.Equal(t, "DE", cost.Addr.Country) + require.False(t, cost.Addr.IsBusiness) + require.Equal(t, printJobCostSample.SuggestedAddr, cost.SuggestedAddr) + require.Equal(t, printJobCostSample.AddrWarnings, cost.AddrWarnings) + requireCurrency(t, cost.Currency) + for _, fee := range cost.Fees { + requireCurrency(t, fee.Currency) + require.NotEmpty(t, fee.Type) + require.NotEmpty(t, fee.Sku) + requireNotZeroDec(t, fee.TaxRate) + requireNotZeroDec(t, fee.TotalCostExclTax) + requireNotZeroDec(t, fee.TotalCostInclTax) + requireNotZeroDec(t, fee.TotalTax) + } + requireNotZeroDec(t, cost.FulfillmentCost.TaxRate) + requireNotZeroDec(t, cost.FulfillmentCost.TotalCostExclTax) + requireNotZeroDec(t, cost.FulfillmentCost.TotalCostInclTax) + requireNotZeroDec(t, cost.FulfillmentCost.TotalTax) + require.NotEmpty(t, cost.LineItemCosts) + for _, lic := range cost.LineItemCosts { + requireNotZeroDec(t, lic.CostExclDiscounts) + for _, discount := range lic.Discounts { + require.Equal(t, "Volume Discount 5%", discount.Description) + requireNotZeroDec(t, discount.Amount) + } + require.NotZero(t, lic.Quantity) + requireNotZeroDec(t, lic.TaxRate) + requireNotZeroDec(t, lic.TotalCostExclDiscounts) + requireNotZeroDec(t, lic.TotalCostExclTax) + requireNotZeroDec(t, lic.TotalCostInclTax) + requireNotZeroDec(t, lic.TotalTax) + requireNotZeroDec(t, lic.UnitTierCost) } + requireNotZeroDec(t, cost.ShipCost.TaxRate) + requireNotZeroDec(t, cost.ShipCost.TotalCostExclTax) + requireNotZeroDec(t, cost.ShipCost.TotalCostInclTax) + requireNotZeroDec(t, cost.ShipCost.TotalTax) + requireNotZeroDec(t, cost.TotalCostExclTax) + requireNotZeroDec(t, cost.TotalCostInclTax) + requireNotZeroDec(t, cost.TotalDiscount) + requireNotZeroDec(t, cost.TotalTax) +} + +func requireCurrency(t *testing.T, s string) { + t.Helper() + _, err := currency.ParseISO(s) + require.NoError(t, err) +} - requireUnmarshalJsonEq(t, cost, j) +func requireNotZeroDec(t *testing.T, d decimal.Decimal) { + t.Helper() + require.False(t, d.IsZero()) } @@ -58,6 +58,6 @@ type errDecResp struct { func (e errDecResp) Error() string { req := e.Response.Request - return fmt.Sprintf("%s %s: error decoding response body %q: %v", + return fmt.Sprintf("%s %s: error decoding response body `%s`: %v", req.Method, req.URL, string(e.body), e.error) } @@ -6,6 +6,7 @@ require ( github.com/shopspring/decimal v1.4.0 github.com/stretchr/testify v1.11.1 golang.org/x/oauth2 v0.36.0 + golang.org/x/text v0.37.0 ) require ( @@ -13,8 +14,8 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/yawnak/string-enumer v0.0.0-20250330104602-f50db3525c45 // indirect - golang.org/x/mod v0.24.0 // indirect - golang.org/x/sync v0.12.0 // indirect - golang.org/x/tools v0.31.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/tools v0.44.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) @@ -10,14 +10,16 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/yawnak/string-enumer v0.0.0-20250330104602-f50db3525c45 h1:0iUqJCEe0kuryd8KPqhnnczbXrD6r66Qrlus4Q7xrb4= github.com/yawnak/string-enumer v0.0.0-20250330104602-f50db3525c45/go.mod h1:14IQUPrpaeA3sucvDb4CoBSnKSaIrXhKZOfdy4ZIx1E= -golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= -golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= -golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= @@ -7,8 +7,10 @@ import ( "encoding/json" "fmt" "io" + "log" "net/http" "net/url" + "os" "golang.org/x/oauth2/clientcredentials" ) @@ -21,6 +23,7 @@ const ( validateInteriorPath = "/validate-interior" coverDimensionsPath = "/cover-dimensions" validateCoverPath = "/validate-cover" + printJobCostPath = "/print-job-cost-calculations" ) // ApiUrl is the location of the API server. It is set to the sandbox @@ -28,6 +31,11 @@ const ( // you are ready to deploy. var ApiUrl = SandboxUrl +var ( + Debug = false // print debug info to stdout + debugLog = log.New(os.Stderr, "DEBUG ", log.LstdFlags|log.Llongfile) +) + type Client struct { c *http.Client } @@ -143,6 +151,29 @@ func (c *Client) GetCoverValidation(id uint) (CoverValidationRecord, error) { return rec, nil } +// PrintJobCost calculates the cost of a hypothetical print order without +// actually creating a print job. +// +// https://api.lulu.com/docs/#tag/Print-Job-Cost-Calculations/operation/Print-Job-cost-calculations_create +func (c *Client) PrintJobCost(items []PrintJobCostLineItem, addr ShippingAddress, shipOpt ShippingLevel) (PrintJobCost, error) { + reqAddr := printJobCostReqShipAddr{ + City: addr.City, + Country: addr.Country, + PostCode: addr.PostCode, + State: addr.State, + Street1: addr.Street1, + Phone: addr.Phone, + } + payload := printJobCostReq{items, reqAddr, shipOpt} + var cost PrintJobCost + err := c.postDecode(printJobCostPath, payload, http.StatusCreated, &cost) + if err != nil { + return cost, pkgErr(err) + } + return cost, nil +} + +// getDecode sends a GET request and unmarshals the response. func (c *Client) getDecode(path string, v any) error { url, err := url.JoinPath(ApiUrl, path) if err != nil { @@ -159,6 +190,7 @@ func (c *Client) getDecode(path string, v any) error { return decodeResponse(resp, v) } +// postDecode sends a POST request and unmarshals the response. func (c *Client) postDecode(path string, payload any, wantStatus int, v any) error { resp, err := c.post(path, payload) if err != nil { @@ -184,13 +216,21 @@ func (c *Client) post(path string, payload any) (*http.Response, error) { } func decodeResponse(resp *http.Response, v any) error { - buf := new(bytes.Buffer) - if _, err := io.Copy(buf, resp.Body); err != nil { + body, err := io.ReadAll(resp.Body) + if err != nil { return errReadResp{resp, err} } - dec := json.NewDecoder(buf) - if err := dec.Decode(v); err != nil { - return errDecResp{resp, buf.Bytes(), err} + + debugf("%s %s response: `%s`\n", resp.Request.Method, resp.Request.URL, body) + + if err := json.Unmarshal(body, v); err != nil { + return errDecResp{resp, body, err} } return nil } + +func debugf(format string, a ...any) { + if Debug { + debugLog.Printf(format, a...) + } +} diff --git a/lulu_test.go b/lulu_test.go index f657681..648ede7 100644 --- a/lulu_test.go +++ b/lulu_test.go @@ -31,6 +31,8 @@ var ( ) func TestMain(m *testing.M) { + Debug = true + if b, err := os.ReadFile(clientKeyPath); err == nil { clientKey = strings.TrimSpace(string(b)) } else { @@ -0,0 +1,11 @@ +-- Print Job Cost -- + +The API doc is either out of date or was never correct to begin with. +The response fields in the doc are different from what the server +actually responds with. Therefore testdata/printjobcostresp.json is +the union of the doc example and a real response from the sandbox +server. For instance, the doc example has a "fees" field while the +real response did not; thus "fees" is included in the testdata. The +real response had "first_name" and "last_name" rather than just +"name", so ShippingAddress has a custom UnmarshalJSON() function to +account for both. And so on... @@ -1,6 +1,11 @@ package lulu -//go:generate go run github.com/yawnak/string-enumer -t ShippingLevel --text -o ./ship_gen.go . +import ( + "encoding/json" + "fmt" +) + +//go:generate go run github.com/yawnak/string-enumer -t ShippingLevel -t Title --text -o ./ship_gen.go . // ShippingLevel is the quality/speed with which a package is shipped. type ShippingLevel string @@ -14,13 +19,107 @@ const ( ) type ShippingAddress struct { - Name string `json:"name"` - Phone string `json:"phone_number"` - CountryCode string `json:"country_code"` - PostCode string `json:"postcode"` - StateCode string `json:"state_code"` - City string `json:"city"` - Street1 string `json:"street1"` - Street2 string `json:"street2"` - IsBusiness bool `json:"is_business"` + Country string // ISO 3166-2 country code + State string // 2 or 3 letter state codes (officially called ISO-3166-2 subdivision codes). They are required for some countries (e.g. US, MX, CA, AU) + City string + Street1 string // First address line + Street2 string // Second address line + PostCode string // Required for most countries + IsBusiness bool // Only relevant for US addresses. Some US carriers don't deliver to business-addresses on Saturday. + Name string // Full name of the person, including first and last name. + Title Title + Organization string // Name of an organization. Required if no person name is given. + Email string // Shipping carriers require an email address for notifications or handling delivery issues. If no email is given, the default email in the user profile will be used. + Phone string // Shipping carriers require a phone number for handling delivery issues. If no phone number is given, the default in the API user profile will be used. + TaxId string // The recipient’s tax identification number. Required for shipping addresses to Brazil, Chile, and Mexico. +} + +// They use "country" in some places, "country_code" in others, etc. +// This is the union of all of them. +type shippingAddress struct { + Country string `json:"country"` + CountryCode string `json:"country_code"` + State string `json:"state"` + StateCode string `json:"state_code"` + City string `json:"city"` + Street1 string `json:"street1"` + Street2 string `json:"street2"` + PostCode string `json:"postcode"` + IsBusiness bool `json:"is_business"` + Name string `json:"name"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Title Title `json:"title"` + Organization string `json:"organization"` + Email string `json:"email"` + Phone string `json:"phone_number"` + TaxId string `json:"recipient_tax_id"` +} + +func (a *ShippingAddress) UnmarshalJSON(data []byte) error { + s := string(data) + var alias shippingAddress + if err := json.Unmarshal(data, &alias); err != nil { + return err + } + + if country, both := either(alias.Country, alias.CountryCode); !both { + a.Country = country + } else { + return fmt.Errorf(`address contains both "country" and "country_code": %q`, s) + } + + if state, both := either(alias.State, alias.StateCode); !both { + a.State = state + } else { + return fmt.Errorf(`address contains both "state" and "state_code": %q`, s) + } + + a.City = alias.City + a.Street1 = alias.Street1 + a.Street2 = alias.Street2 + a.PostCode = alias.PostCode + a.IsBusiness = alias.IsBusiness + + hasName := alias.Name != "" + hasFirst := alias.FirstName != "" + hasLast := alias.LastName != "" + if hasName && (hasFirst || hasLast) { + return fmt.Errorf(`address contains both "name" and {"first_name", "last_name"}: %q`, s) + } else if hasName { + a.Name = alias.Name + } else if hasFirst && hasLast { + a.Name = fmt.Sprintf("%s %s", alias.FirstName, alias.LastName) + } else if hasLast { + a.Name = alias.LastName + } else if hasFirst { + a.Name = alias.FirstName + } + + a.Title = alias.Title + a.Organization = alias.Organization + a.Email = alias.Email + a.Phone = alias.Phone + a.TaxId = alias.TaxId + + return nil +} + +func either(a, b string) (string, bool) { + if a != "" && b != "" { + return a, true + } else if a != "" { + return a, false + } + return b, false } + +type Title string + +const ( + Mr Title = "MR" + Miss Title = "MISS" + Mrs Title = "MRS" + Ms Title = "MS" + Dr Title = "DR" +) diff --git a/testdata/printjobcostreq.json b/testdata/printjobcostreq.json new file mode 100644 index 0000000..d521f77 --- /dev/null +++ b/testdata/printjobcostreq.json @@ -0,0 +1,23 @@ +{ + "line_items": [ + { + "page_count": 32, + "pod_package_id": "0600X0900.BW.STD.PB.060UW444.MXX", + "quantity": 20 + }, + { + "page_count": 324, + "pod_package_id": "0425X0687.BW.STD.PB.060UW444.GXX", + "quantity": 200 + } + ], + "shipping_address": { + "city": "Lübeck", + "country_code": "DE", + "postcode": "23552", + "state_code": "", + "street1": "Holstenstr. 40", + "phone_number": "844-212-0689" + }, + "shipping_option": "EXPRESS" +}
\ No newline at end of file diff --git a/testdata/printjobcostresp.json b/testdata/printjobcostresp.json new file mode 100644 index 0000000..f633183 --- /dev/null +++ b/testdata/printjobcostresp.json @@ -0,0 +1,95 @@ +{ + "shipping_address": { + "city": "Lübeck", + "postcode": "23552", + "street1": "Holstenstr. 40", + "phone_number": "844-212-0689", + "state": "", + "country": "DE", + "is_business": false, + "first_name": ".", + "last_name": ".", + "warnings": [ + { + "type": "validation_warning", + "code": "REPLACED", + "path": "external", + "message": "street1: Holstenstr. 40 -> Holstenstraße 40" + } + ], + "suggested_address": { + "country_code": "DE", + "state_code": null, + "postcode": "23552", + "city": "Lübeck", + "street1": "Holstenstraße 40", + "street2": null + } + }, + "fees": [ + { + "currency": "USD", + "fee_type": "HANDLING_FEE", + "sku": "HANDLING_FEE", + "tax_rate": "0.060000", + "total_cost_excl_tax": "14.00", + "total_cost_incl_tax": "14.84", + "total_tax": "0.84" + }, + { + "currency": "USD", + "fee_type": "FULFILLMENT_FEE", + "sku": "FULFILLMENT_FEE", + "tax_rate": "0.060000", + "total_cost_excl_tax": "0.75", + "total_cost_incl_tax": "0.80", + "total_tax": "0.05" + } + ], + "line_item_costs": [ + { + "cost_excl_discounts": "4.95", + "total_tax": "6.93", + "tax_rate": "0.070000", + "quantity": 20, + "total_cost_excl_tax": "99.00", + "total_cost_excl_discounts": "99.00", + "total_cost_incl_tax": "105.93", + "discounts": [], + "unit_tier_cost": "4.95" + }, + { + "cost_excl_discounts": "16.19", + "total_tax": "215.33", + "tax_rate": "0.070000", + "quantity": 200, + "total_cost_excl_tax": "3076.10", + "total_cost_excl_discounts": "3238.00", + "total_cost_incl_tax": "3291.43", + "discounts": [ + { + "amount": "161.90", + "description": "Volume Discount 5%" + } + ], + "unit_tier_cost": "16.19" + } + ], + "shipping_cost": { + "total_cost_excl_tax": "1085.50", + "total_cost_incl_tax": "1161.49", + "total_tax": "75.99", + "tax_rate": "0.19" + }, + "fulfillment_cost": { + "total_cost_excl_tax": "1.05", + "total_cost_incl_tax": "1.12", + "total_tax": "0.07", + "tax_rate": "0.19" + }, + "total_tax": "298.32", + "total_cost_excl_tax": "4261.65", + "total_cost_incl_tax": "4559.97", + "total_discount_amount": "161.90", + "currency": "CAD" +} |