package main import ( "context" "encoding/json" "fmt" "net/http" "time" "git.k6n.net/mats/go-cart-actor/pkg/flow" "git.k6n.net/mats/go-cart-actor/pkg/order" messages "git.k6n.net/mats/go-cart-actor/proto/order" ) // fromCheckoutReq is the payload the checkout service sends to create an order // from a settled (paid) checkout. All payment processing has already happened // via Klarna/Adyen on the checkout grain; the order service records the result // in its event-sourced grain. type fromCheckoutReq struct { CheckoutId string `json:"checkoutId"` IdempotencyKey string `json:"idempotencyKey"` Flow string `json:"flow,omitempty"` CartId string `json:"cartId"` Currency string `json:"currency"` Locale string `json:"locale,omitempty"` Country string `json:"country"` CustomerEmail string `json:"customerEmail,omitempty"` CustomerName string `json:"customerName,omitempty"` Lines []lineReq `json:"lines"` // Payment carries the externally-settled payment result. The provider has // already authorized and captured; the order grain records these facts. Payment struct { Provider string `json:"provider"` // "klarna" or "adyen" Reference string `json:"reference"` // processor reference (PSP reference) Amount int64 `json:"amount"` // minor units } `json:"payment"` } // handleFromCheckout creates an event-sourced order from a settled checkout. // It runs the place-and-pay flow with a passthrough provider that records the // already-completed authorization and capture, bypassing any external payment // call. The response carries the order grain, flow result, and order ID. // // Idempotent: a retry with the same idempotencyKey returns the existing order // (409 Conflict) rather than creating a duplicate. func (s *server) handleFromCheckout(w http.ResponseWriter, r *http.Request) { var req fromCheckoutReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, fmt.Errorf("bad body: %w", err)) return } if len(req.Lines) == 0 { writeErr(w, http.StatusBadRequest, fmt.Errorf("from-checkout: at least one line required")) return } ordID, flowRes, grain, exists, runErr, err := s.createOrderFromCheckoutInternal(r.Context(), &req) if err != nil { writeErr(w, http.StatusInternalServerError, err) return } if exists { writeJSON(w, http.StatusConflict, map[string]any{ "orderId": order.OrderId(ordID).String(), "order": grain, "existing": true, }) return } status := http.StatusCreated if runErr != nil { status = http.StatusPaymentRequired } writeJSON(w, status, map[string]any{ "orderId": order.OrderId(ordID).String(), "flow": flowRes, "order": grain, }) } // createOrderFromCheckoutInternal runs the core idempotent checkout-to-order logic. // It returns (orderID, flowResult, grain, exists, runErr, err). // - exists is true if the order already existed (idempotency hit). // - runErr is the flow run error (payment flow failed, e.g. compensating was run). // - err is a structural/storage error. func (s *server) createOrderFromCheckoutInternal(ctx context.Context, req *fromCheckoutReq) (uint64, *flow.Result, *order.OrderGrain, bool, error, error) { if req.IdempotencyKey != "" { unlock := s.idem.Lock(req.IdempotencyKey) defer unlock() if existingID, ok := s.idem.Get(req.IdempotencyKey); ok { g, err := s.applier.Get(ctx, existingID) if err != nil { return 0, nil, nil, false, nil, fmt.Errorf("idempotent lookup: %w", err) } return existingID, nil, g, true, nil, nil } } id, err := order.NewOrderId() if err != nil { return 0, nil, nil, false, nil, fmt.Errorf("new order id: %w", err) } ordID := uint64(id) po := buildFromCheckoutPlaceOrder(id, req) provider := order.NewPassthroughProvider(req.Payment.Provider, req.Payment.Reference, req.Payment.Amount) flowName := req.Flow if flowName == "" { flowName = s.defaultFlow } def, ok := s.flows.get(flowName) if !ok { return 0, nil, nil, false, nil, fmt.Errorf("unknown flow %q", flowName) } freg := flow.NewRegistry() flow.RegisterBuiltinHooks(freg) order.RegisterFlowActions(freg, s.applier, provider) order.RegisterEmitHook(freg, nil) engine := flow.NewEngine(freg, s.logger) st := flow.NewState(ordID, s.logger) st.Vars[order.PlaceOrderVar] = po res, runErr := engine.Run(ctx, def, st) grain, getErr := s.applier.Get(ctx, ordID) if getErr != nil { return 0, nil, nil, false, nil, fmt.Errorf("get order grain: %w", getErr) } if runErr != nil { s.logger.Warn("from-checkout flow failed", "orderId", id.String(), "err", runErr) } if req.IdempotencyKey != "" && runErr == nil { if err := s.idem.Put(req.IdempotencyKey, ordID); err != nil { s.logger.Error("idempotency record failed", "key", req.IdempotencyKey, "orderId", id.String(), "err", err) } } return ordID, res, grain, false, runErr, nil } // buildFromCheckoutPlaceOrder converts a from-checkout request into a PlaceOrder // proto message, computing per-line and order totals from the lineReq entries. func buildFromCheckoutPlaceOrder(id order.OrderId, req *fromCheckoutReq) *messages.PlaceOrder { orderRef := "ord-" + id.String() po := &messages.PlaceOrder{ OrderReference: orderRef, CartId: req.CartId, Currency: req.Currency, Locale: req.Locale, Country: req.Country, CustomerEmail: req.CustomerEmail, CustomerName: req.CustomerName, PlacedAtMs: time.Now().UnixMilli(), } var total, totalTax int64 for _, l := range req.Lines { lineTotal := l.UnitPrice * int64(l.Quantity) lineTax := order.ComputeTax(lineTotal, int(l.TaxRate)) total += lineTotal totalTax += lineTax po.Lines = append(po.Lines, &messages.OrderLine{ Reference: l.Reference, Sku: l.Sku, Name: l.Name, Quantity: l.Quantity, UnitPrice: l.UnitPrice, TaxRate: l.TaxRate, TotalAmount: lineTotal, TotalTax: lineTax, }) } po.TotalAmount = total po.TotalTax = totalTax return po }