aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore4
-rw-r--r--Makefile3
-rw-r--r--cost.go134
-rw-r--r--cost_test.go417
-rw-r--r--err.go2
-rw-r--r--go.mod7
-rw-r--r--go.sum14
-rw-r--r--lulu.go50
-rw-r--r--lulu_test.go2
-rw-r--r--notes11
-rw-r--r--ship.go119
-rw-r--r--testdata/printjobcostreq.json23
-rw-r--r--testdata/printjobcostresp.json95
13 files changed, 539 insertions, 342 deletions
diff --git a/.gitignore b/.gitignore
index 647f879..07aa8ec 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,5 @@
*_gen.go
-testdata
+spec.yml
+testdata/clientkey
+testdata/clientsecret
todo
diff --git a/Makefile b/Makefile
index ce6ef7e..3f144ca 100644
--- a/Makefile
+++ b/Makefile
@@ -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}
diff --git a/cost.go b/cost.go
index 202fcde..ab605ce 100644
--- a/cost.go
+++ b/cost.go
@@ -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())
}
diff --git a/err.go b/err.go
index cb79500..58984dc 100644
--- a/err.go
+++ b/err.go
@@ -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)
}
diff --git a/go.mod b/go.mod
index eda2d03..5f4d74d 100644
--- a/go.mod
+++ b/go.mod
@@ -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
)
diff --git a/go.sum b/go.sum
index e9731b8..e8912ba 100644
--- a/go.sum
+++ b/go.sum
@@ -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=
diff --git a/lulu.go b/lulu.go
index 9e567a7..302bfc5 100644
--- a/lulu.go
+++ b/lulu.go
@@ -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 {
diff --git a/notes b/notes
new file mode 100644
index 0000000..331cabe
--- /dev/null
+++ b/notes
@@ -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...
diff --git a/ship.go b/ship.go
index aef655e..b5aa822 100644
--- a/ship.go
+++ b/ship.go
@@ -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"
+}