diff --git a/src/checkoutservice/main.go b/src/checkoutservice/main.go index 8a2990e..93a2e3f 100644 --- a/src/checkoutservice/main.go +++ b/src/checkoutservice/main.go @@ -7,15 +7,17 @@ import ( "net" "os" + "github.com/google/uuid" + "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" pb "./genproto" - "google.golang.org/grpc" ) const ( - listenPort = "5050" + listenPort = "5050" + usdCurrency = "USD" ) type checkoutService struct { @@ -64,30 +66,197 @@ func mustMapEnv(target *string, envKey string) { func (cs *checkoutService) CreateOrder(ctx context.Context, req *pb.CreateOrderRequest) (*pb.CreateOrderResponse, error) { log.Printf("[CreateOrder] user_id=%q user_currency=%q", req.UserId, req.UserCurrency) resp := new(pb.CreateOrderResponse) - conn, err := grpc.Dial(cs.shippingSvcAddr, grpc.WithInsecure()) - if err != nil { - return nil, status.Errorf(codes.Unavailable, "could not connect shippping service: %+v", err) - } - defer conn.Close() - shippingQuote, err := pb.NewShippingServiceClient(conn). - GetQuote(ctx, &pb.GetQuoteRequest{ - Address: req.Address, - Items: nil}) // TODO(ahmetb): query CartService for items + shippingQuoteUSD, err := cs.quoteShipping(ctx, req.Address, nil) // TODO(ahmetb): query CartService for items if err != nil { - return nil, status.Errorf(codes.Unavailable, "failed to get shipping quote: %+v", err) + return nil, status.Errorf(codes.Internal, "shipping quote failure: %+v", err) } resp.ShippingCost = &pb.Money{ - Amount: shippingQuote.GetCostUsd(), - CurrencyCode: "USD", // TOD(ahmetb) convert to req.UserCurrency + Amount: shippingQuoteUSD, + CurrencyCode: "USD", } + // TODO(ahmetb) convert to req.UserCurrency // TODO(ahmetb) calculate resp.OrderItem with req.UserCurrency - return resp, nil } func (cs *checkoutService) PlaceOrder(ctx context.Context, req *pb.PlaceOrderRequest) (*pb.PlaceOrderResponse, error) { log.Printf("[PlaceOrder] user_id=%q user_currency=%q", req.UserId, req.UserCurrency) - resp := new(pb.PlaceOrderResponse) + + orderID, err := uuid.NewUUID() + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to generate order uuid") + } + + cartItems, err := cs.getUserCart(ctx, req.UserId) + if err != nil { + return nil, status.Errorf(codes.Internal, "cart failure: %+v", err) + } + + orderItems, err := cs.prepOrderItems(ctx, cartItems, req.UserCurrency) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to prepare order: %+v", err) + } + + shippingUsd, err := cs.quoteShipping(ctx, req.Address, cartItems) // TODO(ahmetb): query CartService for items + if err != nil { + return nil, status.Errorf(codes.Internal, "shipping quote failure: %+v", err) + } + shippingPrice, err := cs.convertCurrency(ctx, &pb.Money{ + Amount: shippingUsd, + CurrencyCode: usdCurrency}, req.UserCurrency) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to convert shipping cost to currency: %+v", err) + } + + var totalPrice pb.Money + totalPrice = sumMoney(totalPrice, *shippingPrice) + for _, it := range orderItems { + totalPrice = sumMoney(totalPrice, *it.Cost) + } + + txID, err := cs.chargeCard(ctx, &totalPrice, req.CreditCard) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to charge card: %+v", err) + } + log.Printf("payment went through (transaction_id: %s)", txID) + + shippingTrackingID, err := cs.shipOrder(ctx, req.Address, cartItems) + if err != nil { + return nil, status.Errorf(codes.Unavailable, "shipping error: %+v", err) + } + + orderResult := &pb.OrderResult{ + OrderId: orderID.String(), + ShippingTrackingId: shippingTrackingID, + ShippingCost: shippingPrice, + ShippingAddress: req.Address, + Items: orderItems, + } + + if err := cs.sendOrderConfirmation(ctx, req.Email, orderResult); err != nil { + log.Printf("failed to send order confirmation to %q: %+v", req.Email, err) + } else { + log.Printf("order confirmation email sent to %q", req.Email) + } + resp := &pb.PlaceOrderResponse{Order: orderResult} return resp, nil } + +func (cs *checkoutService) quoteShipping(ctx context.Context, address *pb.Address, items []*pb.CartItem) (*pb.MoneyAmount, error) { + conn, err := grpc.DialContext(ctx, cs.shippingSvcAddr, grpc.WithInsecure()) + if err != nil { + return nil, fmt.Errorf("could not connect shipping service: %+v", err) + } + defer conn.Close() + + shippingQuote, err := pb.NewShippingServiceClient(conn). + GetQuote(ctx, &pb.GetQuoteRequest{ + Address: address, + Items: items}) + if err != nil { + return nil, fmt.Errorf("failed to get shipping quote: %+v", err) + } + return shippingQuote.GetCostUsd(), nil +} + +func (cs *checkoutService) getUserCart(ctx context.Context, userID string) ([]*pb.CartItem, error) { + conn, err := grpc.DialContext(ctx, cs.cartSvcAddr, grpc.WithInsecure()) + if err != nil { + return nil, fmt.Errorf("could not connect cart service: %+v", err) + } + defer conn.Close() + + cart, err := pb.NewCartServiceClient(conn).GetCart(ctx, &pb.GetCartRequest{UserId: userID}) + if err != nil { + return nil, fmt.Errorf("failed to get user cart: %+v", err) + } + return cart.GetItems(), nil +} + +func (cs *checkoutService) prepOrderItems(ctx context.Context, items []*pb.CartItem, userCurrency string) ([]*pb.OrderItem, error) { + out := make([]*pb.OrderItem, len(items)) + + conn, err := grpc.DialContext(ctx, cs.productCatalogSvcAddr, grpc.WithInsecure()) + if err != nil { + return nil, fmt.Errorf("could not connect product catalog service: %+v", err) + } + defer conn.Close() + cl := pb.NewProductCatalogServiceClient(conn) + + for i, item := range items { + product, err := cl.GetProduct(ctx, &pb.GetProductRequest{Id: item.GetProductId()}) + if err != nil { + return nil, fmt.Errorf("failed to get product #%q", item.GetProductId()) + } + usdPrice := &pb.Money{ + Amount: product.GetPriceUsd(), + CurrencyCode: usdCurrency} + price, err := cs.convertCurrency(ctx, usdPrice, userCurrency) + if err != nil { + return nil, fmt.Errorf("failed to convert price of %q to %s", item.GetProductId(), userCurrency) + } + out[i] = &pb.OrderItem{ + Item: item, + Cost: price} + } + return out, nil +} + +func (cs *checkoutService) convertCurrency(ctx context.Context, from *pb.Money, toCurrency string) (*pb.Money, error) { + conn, err := grpc.DialContext(ctx, cs.currencySvcAddr, grpc.WithInsecure()) + if err != nil { + return nil, fmt.Errorf("could not connect currency service: %+v", err) + } + defer conn.Close() + result, err := pb.NewCurrencyServiceClient(conn).Convert(context.TODO(), &pb.CurrencyConversionRequest{ + From: from, + ToCode: toCurrency}) + if err != nil { + return nil, fmt.Errorf("failed to convert currency: %+v", err) + } + return result, err +} + +func (cs *checkoutService) chargeCard(ctx context.Context, amount *pb.Money, paymentInfo *pb.CreditCardInfo) (string, error) { + conn, err := grpc.DialContext(ctx, cs.paymentSvcAddr, grpc.WithInsecure()) + if err != nil { + return "", fmt.Errorf("failed to connect payment service: %+v", err) + } + defer conn.Close() + + paymentResp, err := pb.NewPaymentServiceClient(conn).Charge(ctx, &pb.ChargeRequest{ + Amount: amount, + CreditCard: paymentInfo}) + if err != nil { + return "", fmt.Errorf("could not charge the card: %+v", err) + } + return paymentResp.GetTransactionId(), nil +} + +func (cs *checkoutService) sendOrderConfirmation(ctx context.Context, email string, order *pb.OrderResult) error { + conn, err := grpc.DialContext(ctx, cs.emailSvcAddr, grpc.WithInsecure()) + if err != nil { + return fmt.Errorf("failed to connect email service: %+v", err) + } + defer conn.Close() + _, err = pb.NewEmailServiceClient(conn).SendOrderConfirmation(ctx, &pb.SendOrderConfirmationRequest{ + Email: email, + Order: order}) + return err +} + +func (cs *checkoutService) shipOrder(ctx context.Context, address *pb.Address, items []*pb.CartItem) (string, error) { + conn, err := grpc.DialContext(ctx, cs.shippingSvcAddr, grpc.WithInsecure()) + if err != nil { + return "", fmt.Errorf("failed to connect email service: %+v", err) + } + defer conn.Close() + resp, err := pb.NewShippingServiceClient(conn).ShipOrder(ctx, &pb.ShipOrderRequest{ + Address: address, + Items: items}) + if err != nil { + return "", fmt.Errorf("shipment failed: %+v", err) + } + return resp.GetTrackingId(), nil +} diff --git a/src/checkoutservice/money.go b/src/checkoutservice/money.go new file mode 100644 index 0000000..4777d1d --- /dev/null +++ b/src/checkoutservice/money.go @@ -0,0 +1,38 @@ +package main + +import ( + "math" + + pb "./genproto" +) + +func sum(m1, m2 pb.MoneyAmount) pb.MoneyAmount { + f1, f2 := float64(m1.Fractional), float64(m2.Fractional) + lg1 := math.Max(1, math.Ceil(math.Log10(f1))) + lg2 := math.Max(1, math.Ceil(math.Log10(f2))) + lgMax := math.Max(lg1, lg2) + + dSum := m1.Decimal + m2.Decimal + o1 := f1 * math.Pow(10, lgMax-lg1) + o2 := f2 * math.Pow(10, lgMax-lg2) + fSum := o1 + o2 + if fSum >= math.Pow(10, lgMax) { + fSum -= math.Pow(10, lgMax) + dSum++ + } + + for int(fSum)%10 == 0 && fSum != 0 { + fSum = float64(int(fSum) / 10) + } + + return pb.MoneyAmount{ + Decimal: dSum, + Fractional: uint32(fSum)} +} + +func sumMoney(m1, m2 pb.Money) pb.Money { + s := sum(*m1.Amount, *m2.Amount) + return pb.Money{ + Amount: &s, + CurrencyCode: m1.CurrencyCode} +} diff --git a/src/checkoutservice/money_test.go b/src/checkoutservice/money_test.go new file mode 100644 index 0000000..29f8a85 --- /dev/null +++ b/src/checkoutservice/money_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "reflect" + "testing" + + pb "./genproto" +) + +func Test_sum(t *testing.T) { + type args struct { + m1 pb.MoneyAmount + m2 pb.MoneyAmount + } + tests := []struct { + name string + args args + want pb.MoneyAmount + }{ + { + name: "no fractions", + args: args{pb.MoneyAmount{Decimal: 10}, pb.MoneyAmount{Decimal: 100}}, + want: pb.MoneyAmount{Decimal: 110}, + }, + { + name: "same fraction digits", + args: args{pb.MoneyAmount{Decimal: 1, Fractional: 23}, pb.MoneyAmount{Decimal: 1, Fractional: 44}}, + want: pb.MoneyAmount{Decimal: 2, Fractional: 67}, + }, + { + name: "different fraction digits", + args: args{pb.MoneyAmount{Decimal: 1, Fractional: 351}, pb.MoneyAmount{Decimal: 1, Fractional: 1}}, + want: pb.MoneyAmount{Decimal: 2, Fractional: 451}, + }, + { + name: "redundant trailing zeroes are removed from fraction", + args: args{pb.MoneyAmount{Decimal: 1, Fractional: 351}, pb.MoneyAmount{Decimal: 1, Fractional: 349}}, + want: pb.MoneyAmount{Decimal: 2, Fractional: 7}, + }, + { + name: "carry", + args: args{pb.MoneyAmount{Decimal: 1, Fractional: 5}, pb.MoneyAmount{Decimal: 1, Fractional: 5000000}}, + want: pb.MoneyAmount{Decimal: 3}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := sum(tt.args.m1, tt.args.m2); !reflect.DeepEqual(got, tt.want) { + t.Errorf("sum(%v+%v) = %v, want=%v", tt.args.m1, tt.args.m2, got, tt.want) + } + }) + } +}