From 716a66562ea28714b7bd4e49d4ea2795f23fa01c Mon Sep 17 00:00:00 2001 From: matst80 Date: Sun, 21 Jun 2026 23:26:37 +0200 Subject: [PATCH] update orderflow --- cmd/checkout/adyen-handlers.go | 58 +++++++++- cmd/checkout/klarna-handlers.go | 98 ++++++++++++++-- cmd/checkout/main.go | 19 +++- cmd/checkout/order_client.go | 112 ++++++++++++++++++ cmd/checkout/order_create.go | 110 ++++++++++++++++++ cmd/checkout/pool-server.go | 16 +-- cmd/checkout/utils.go | 10 ++ cmd/order/handlers_checkout.go | 196 ++++++++++++++++++++++++++++++++ cmd/order/main.go | 1 + pkg/order/passthrough.go | 60 ++++++++++ 10 files changed, 651 insertions(+), 29 deletions(-) create mode 100644 cmd/checkout/order_client.go create mode 100644 cmd/checkout/order_create.go create mode 100644 cmd/order/handlers_checkout.go create mode 100644 pkg/order/passthrough.go diff --git a/cmd/checkout/adyen-handlers.go b/cmd/checkout/adyen-handlers.go index 14767ea..96d7b36 100644 --- a/cmd/checkout/adyen-handlers.go +++ b/cmd/checkout/adyen-handlers.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "errors" "fmt" @@ -124,6 +125,14 @@ func (s *CheckoutPoolServer) AdyenHookHandler(w http.ResponseWriter, r *http.Req } //} + // CAPTURE is the moment the Adyen payment is fully settled — the + // Adyen analogue of the Klarna push. Create the event-sourced + // order now (mirrors KlarnaPushHandler). Idempotent on + // "checkout-{checkoutId}", so a retried CAPTURE won't duplicate. + if isSuccess && s.orderClient != nil { + s.createAdyenOrder(r.Context(), *checkoutId, item) + } + case "AUTHORISATION": isSuccess := item.Success == "true" @@ -176,11 +185,11 @@ func (s *CheckoutPoolServer) AdyenHookHandler(w http.ResponseWriter, r *http.Req if err != nil { log.Printf("Error capturing payment: %v", err) } else { - log.Printf("Payment captured successfully: %+v", res) - s.ApplyAnywhere(r.Context(), *checkoutId, &messages.OrderCreated{ - OrderId: res.PaymentPspReference, - Status: item.EventCode, - }) + // Capture requested. The order is NOT created here — we + // only have a PSP reference, not an order. Adyen sends a + // CAPTURE notification once the capture settles, and the + // CAPTURE branch above creates the event-sourced order. + log.Printf("Payment capture requested successfully: %+v", res) } } default: @@ -209,6 +218,45 @@ func (s *CheckoutPoolServer) AdyenHookHandler(w http.ResponseWriter, r *http.Req } +// createAdyenOrder turns a settled (captured) Adyen payment into an +// event-sourced order, mirroring KlarnaPushHandler. The Adyen webhook carries +// the charged amount + currency but not the shopper's country/locale, so those +// are derived from the currency. Failures are logged and left for the next +// CAPTURE notification to retry — the from-checkout endpoint is idempotent on +// "checkout-{checkoutId}". +func (s *CheckoutPoolServer) createAdyenOrder(ctx context.Context, checkoutId cart.CartId, item webhook.NotificationRequestItem) { + grain, err := s.GetAnywhere(ctx, checkoutId) + if err != nil { + log.Printf("from-checkout: could not load checkout grain %s: %v", checkoutId.String(), err) + return + } + + // Reserve inventory once, guarded by the grain flag so a retried CAPTURE + // does not decrement stock twice. (Same as the Klarna path.) + if s.inventoryService != nil && grain.CartState != nil && !grain.InventoryReserved { + if rerr := s.inventoryService.ReserveInventory(ctx, getInventoryRequests(grain.CartState.Items)...); rerr != nil { + log.Printf("from-checkout: inventory reservation failed for %s: %v", checkoutId.String(), rerr) + } else { + s.Apply(ctx, uint64(grain.Id), &messages.InventoryReserved{ + Id: grain.Id.String(), + Status: "success", + }) + } + } + + country := getCountryFromCurrency(item.Amount.Currency) + if _, oerr := createOrderFromSettledCheckout(ctx, s, grain, settledPayment{ + Provider: "adyen", + Reference: item.PspReference, + Amount: item.Amount.Value, + Currency: item.Amount.Currency, + Country: country, + Locale: getLocale(country), + }); oerr != nil { + log.Printf("from-checkout failed for checkout %s; will retry on next CAPTURE: %v", checkoutId.String(), oerr) + } +} + func (s *CheckoutPoolServer) AdyenReturnHandler(w http.ResponseWriter, r *http.Request) { log.Println("Redirect received") diff --git a/cmd/checkout/klarna-handlers.go b/cmd/checkout/klarna-handlers.go index 3e774d3..f6d11f3 100644 --- a/cmd/checkout/klarna-handlers.go +++ b/cmd/checkout/klarna-handlers.go @@ -205,19 +205,24 @@ func (s *CheckoutPoolServer) KlarnaPushHandler(w http.ResponseWriter, r *http.Re CompletedAt: timestamppb.Now(), }) - // err = confirmOrder(r.Context(), order, orderHandler) - // if err != nil { - // log.Printf("Error confirming order: %v\n", err) - // w.WriteHeader(http.StatusInternalServerError) - // return - // } + // ── Create the event-sourced order grain (dual-write with AMQP) ──── + if s.orderClient != nil { + if _, orderErr := createOrderFromCheckout(r.Context(), s, grain, order, "klarna"); orderErr != nil { + // Non-fatal: the checkout grain is updated, the order can be + // created on retry (idempotency key guards against duplicates). + logger.WarnContext(r.Context(), "from-checkout failed; will retry on next push", + "err", orderErr, "checkoutId", grain.Id.String()) + } + } + + // ── Dual-write: AMQP order-queue for legacy consumers ─────────────── + if s.orderHandler != nil { + legacyBody, _ := buildLegacyOrderJSON(grain, order, "klarna", orderId) + if pubErr := s.orderHandler.OrderCompleted(legacyBody); pubErr != nil { + logger.WarnContext(r.Context(), "order-queue publish failed", "err", pubErr) + } + } - // err = triggerOrderCompleted(r.Context(), a.server, order) - // if err != nil { - // log.Printf("Error processing cart message: %v\n", err) - // w.WriteHeader(http.StatusInternalServerError) - // return - // } err = s.klarnaClient.AcknowledgeOrder(r.Context(), orderId) if err != nil { log.Printf("Error acknowledging order: %v\n", err) @@ -240,6 +245,75 @@ var tpl = ` ` +// createOrderFromCheckout builds and sends the from-checkout request to the +// order service, creating an event-sourced order grain from the settled checkout. +// The idempotency key is "checkout-{checkoutId}" so that retries (Klarna may +// push the same order multiple times) are safe. +func createOrderFromCheckout(ctx context.Context, s *CheckoutPoolServer, grain *checkout.CheckoutGrain, klarnaOrder *CheckoutOrder, provider string) (*CreateOrderResult, error) { + return createOrderFromSettledCheckout(ctx, s, grain, settledPayment{ + Provider: provider, + Reference: klarnaOrder.ID, + Amount: int64(klarnaOrder.OrderAmount), + Currency: klarnaOrder.PurchaseCurrency, + Country: klarnaOrder.PurchaseCountry, + Locale: klarnaOrder.Locale, + }) +} + +// buildLegacyOrderJSON builds the go-order-manager compatible JSON for AMQP +// dual-write, so existing downstream consumers on the order-queue still receive +// order events during the migration period. +func buildLegacyOrderJSON(grain *checkout.CheckoutGrain, klarnaOrder *CheckoutOrder, provider string, orderId string) ([]byte, error) { + if grain.CartState == nil { + return nil, fmt.Errorf("buildLegacyOrderJSON: checkout %s has no cart state", grain.Id) + } + type legacyLine struct { + Reference string `json:"reference"` + Name string `json:"name"` + Quantity int `json:"quantity"` + UnitPrice int `json:"unit_price"` + TaxRate int `json:"tax_rate"` + TotalAmount int `json:"total_amount"` + TotalTaxAmount int `json:"total_tax_amount"` + } + type legacyOrder struct { + ID string `json:"order_id"` + PurchaseCountry string `json:"purchase_country"` + PurchaseCurrency string `json:"purchase_currency"` + Locale string `json:"locale"` + OrderAmount int `json:"order_amount"` + OrderTaxAmount int `json:"order_tax_amount"` + OrderLines []legacyLine `json:"order_lines"` + } + + lines := make([]legacyLine, 0, len(grain.CartState.Items)+len(grain.Deliveries)) + for _, it := range grain.CartState.Items { + if it == nil { + continue + } + lines = append(lines, legacyLine{ + Reference: it.Sku, + Name: it.Meta.Name, + Quantity: int(it.Quantity), + UnitPrice: int(it.Price.IncVat), + TaxRate: it.Tax, + TotalAmount: int(it.TotalPrice.IncVat), + TotalTaxAmount: int(it.TotalPrice.TotalVat()), + }) + } + + lo := legacyOrder{ + ID: orderId, + PurchaseCountry: klarnaOrder.PurchaseCountry, + PurchaseCurrency: klarnaOrder.PurchaseCurrency, + Locale: klarnaOrder.Locale, + OrderAmount: klarnaOrder.OrderAmount, + OrderTaxAmount: klarnaOrder.OrderTaxAmount, + OrderLines: lines, + } + return json.Marshal(lo) +} + func getLocationId(item *cart.CartItem) inventory.LocationID { if item.StoreId == nil || *item.StoreId == "" { return "se" diff --git a/cmd/checkout/main.go b/cmd/checkout/main.go index 11d5ad4..826bb4b 100644 --- a/cmd/checkout/main.go +++ b/cmd/checkout/main.go @@ -16,6 +16,7 @@ import ( "github.com/adyen/adyen-go-api-library/v21/src/adyen" "github.com/adyen/adyen-go-api-library/v21/src/common" "git.k6n.net/mats/go-redis-inventory/pkg/inventory" + amqp "github.com/rabbitmq/amqp091-go" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" "github.com/redis/go-redis/v9" @@ -115,7 +116,23 @@ func main() { cartClient := NewCartClient(cartInternalUrl) - syncedServer := NewCheckoutPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient, cartClient, adyenClient) + var orderClient *OrderClient + if orderServiceURL := os.Getenv("ORDER_SERVICE_URL"); orderServiceURL != "" { + orderClient = NewOrderClient(orderServiceURL) + log.Printf("order client enabled: %s", orderServiceURL) + } + + var orderHandler *AmqpOrderHandler + conn, err := amqp.Dial(amqpUrl) + if err != nil { + log.Fatalf("failed to connect to RabbitMQ: %v", err) + } + orderHandler = NewAmqpOrderHandler(conn) + if err := orderHandler.DefineQueue(); err != nil { + log.Fatalf("failed to declare order queue: %v", err) + } + + syncedServer := NewCheckoutPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient, cartClient, adyenClient, orderClient, orderHandler) syncedServer.inventoryService = inventoryService mux := http.NewServeMux() diff --git a/cmd/checkout/order_client.go b/cmd/checkout/order_client.go new file mode 100644 index 0000000..c443897 --- /dev/null +++ b/cmd/checkout/order_client.go @@ -0,0 +1,112 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" +) + +// OrderClient is a minimal HTTP client for the order service. It is the +// checkout service's counterpart to the CartClient — the same pattern, but +// calling into order-service endpoints instead of cart-service ones. +// +// Currently only CreateOrder is implemented (the from-checkout handoff). +// Add GetOrder/ListOrders methods as the backoffice order UI evolves. +type OrderClient struct { + httpClient *http.Client + baseURL string // e.g. "http://order-service:8092" +} + +// NewOrderClient returns a client that talks to the order service at baseURL. +func NewOrderClient(baseURL string) *OrderClient { + return &OrderClient{ + httpClient: &http.Client{Timeout: 30 * time.Second}, + baseURL: baseURL, + } +} + +// CreateOrderReq is the JSON payload POSTed to the order service's +// /api/orders/from-checkout endpoint. Every field is required except where +// noted. See cmd/order/handlers_checkout.go for the full definition. +type CreateOrderReq struct { + CheckoutId string `json:"checkoutId"` + IdempotencyKey string `json:"idempotencyKey"` + + 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 []OrderLine `json:"lines"` + + Payment struct { + Provider string `json:"provider"` + Reference string `json:"reference"` + Amount int64 `json:"amount"` + } `json:"payment"` +} + +// OrderLine is one orderable item in the CreateOrderReq, matching the +// lineReq type in the order service. +type OrderLine struct { + Reference string `json:"reference"` + Sku string `json:"sku"` + Name string `json:"name"` + Quantity int32 `json:"quantity"` + UnitPrice int64 `json:"unitPrice"` + TaxRate int32 `json:"taxRate"` +} + +// CreateOrderResult is the success response from POST /api/orders/from-checkout. +type CreateOrderResult struct { + OrderId string `json:"orderId"` + Flow json.RawMessage `json:"flow,omitempty"` + Order json.RawMessage `json:"order,omitempty"` + // Existing is true when the response is a 409 Conflict — the idempotency + // key was already used and this is the previously-created order. + Existing bool `json:"existing,omitempty"` +} + +// CreateOrder sends a settled checkout's data to the order service and returns +// the created (or previously-created, via idempotency) order. An idempotencyKey +// should be generated per checkout (e.g. "checkout-{checkoutId}") to guard +// against retries. flowName overrides the default flow; use "" for the default. +func (c *OrderClient) CreateOrder(ctx context.Context, req CreateOrderReq, flowName string) (*CreateOrderResult, error) { + url := c.baseURL + "/api/orders/from-checkout" + if flowName != "" { + url += "?flow=" + flowName + } + + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("order client: marshal: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("order client: new request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("order client: do: %w", err) + } + defer resp.Body.Close() + + // Both 201 (created) and 409 (idempotent hit) are valid responses. + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusConflict { + return nil, fmt.Errorf("order client: %s", resp.Status) + } + + var result CreateOrderResult + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("order client: decode: %w", err) + } + result.Existing = resp.StatusCode == http.StatusConflict + return &result, nil +} diff --git a/cmd/checkout/order_create.go b/cmd/checkout/order_create.go new file mode 100644 index 0000000..05ca4f5 --- /dev/null +++ b/cmd/checkout/order_create.go @@ -0,0 +1,110 @@ +package main + +import ( + "context" + "fmt" + + "git.k6n.net/mats/go-cart-actor/pkg/checkout" + messages "git.k6n.net/mats/go-cart-actor/proto/checkout" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// settledPayment carries the provider-agnostic facts about an already-settled +// payment. Each payment integration (Klarna push, Adyen CAPTURE notification, +// …) fills this in from its own callback and hands it to +// createOrderFromSettledCheckout, so the from-checkout request is identical +// regardless of which provider settled the payment. +type settledPayment struct { + Provider string // "klarna" | "adyen" + Reference string // processor / PSP reference + Amount int64 // minor units, as charged + Currency string + Country string + Locale string +} + +// buildOrderLinesFromGrain converts the checkout grain's cart items and delivery +// selections into from-checkout order lines. Shared by every provider so the +// order payload is built the same way no matter who settled the payment. +func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine { + lines := make([]OrderLine, 0, len(grain.CartState.Items)+len(grain.Deliveries)) + for _, it := range grain.CartState.Items { + if it == nil { + continue + } + lines = append(lines, OrderLine{ + Reference: it.Sku, + Sku: it.Sku, + Name: it.Meta.Name, + Quantity: int32(it.Quantity), + UnitPrice: it.Price.IncVat, + TaxRate: int32(it.Tax), + }) + } + for _, d := range grain.Deliveries { + if d == nil || d.Price.IncVat <= 0 { + continue + } + lines = append(lines, OrderLine{ + Reference: d.Provider, + Sku: d.Provider, + Name: "Delivery", + Quantity: 1, + UnitPrice: d.Price.IncVat, + TaxRate: 2500, + }) + } + return lines +} + +// createOrderFromSettledCheckout sends the from-checkout request for a settled +// checkout (any provider) and, on success, records the returned order id on the +// checkout grain via the OrderCreated mutation so the backoffice can link +// checkout → order. +// +// It is idempotent: the order service dedupes on "checkout-{checkoutId}", so +// repeated provider callbacks (Klarna re-push, retried Adyen CAPTURE) return the +// existing order rather than creating a duplicate. Callers should treat a +// non-nil error as non-fatal and retry on the next callback. +func createOrderFromSettledCheckout(ctx context.Context, s *CheckoutPoolServer, grain *checkout.CheckoutGrain, pay settledPayment) (*CreateOrderResult, error) { + if grain.CartState == nil { + return nil, fmt.Errorf("from-checkout: checkout %s has no cart state", grain.Id) + } + + var customerEmail, customerName string + if grain.ContactDetails != nil { + if grain.ContactDetails.Email != nil { + customerEmail = *grain.ContactDetails.Email + } + if grain.ContactDetails.Name != nil { + customerName = *grain.ContactDetails.Name + } + } + + req := CreateOrderReq{ + CheckoutId: grain.Id.String(), + IdempotencyKey: "checkout-" + grain.Id.String(), + CartId: grain.CartId.String(), + Currency: pay.Currency, + Locale: pay.Locale, + Country: pay.Country, + CustomerEmail: customerEmail, + CustomerName: customerName, + Lines: buildOrderLinesFromGrain(grain), + } + req.Payment.Provider = pay.Provider + req.Payment.Reference = pay.Reference + req.Payment.Amount = pay.Amount + + result, err := s.orderClient.CreateOrder(ctx, req, "") + if err != nil { + return nil, err + } + + _ = s.ApplyAnywhere(ctx, grain.Id, &messages.OrderCreated{ + OrderId: result.OrderId, + Status: "completed", + CreatedAt: timestamppb.Now(), + }) + return result, nil +} diff --git a/cmd/checkout/pool-server.go b/cmd/checkout/pool-server.go index 60999b1..5e9d15b 100644 --- a/cmd/checkout/pool-server.go +++ b/cmd/checkout/pool-server.go @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "errors" - "log" "net/http" "os" "strconv" @@ -18,7 +17,6 @@ import ( adyen "github.com/adyen/adyen-go-api-library/v21/src/adyen" "git.k6n.net/mats/go-redis-inventory/pkg/inventory" - amqp "github.com/rabbitmq/amqp091-go" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" @@ -51,16 +49,20 @@ type CheckoutPoolServer struct { klarnaClient *KlarnaClient adyenClient *adyen.APIClient cartClient *CartClient + orderClient *OrderClient + orderHandler *AmqpOrderHandler inventoryService *inventory.RedisInventoryService } -func NewCheckoutPoolServer(pool actor.GrainPool[checkout.CheckoutGrain], pod_name string, klarnaClient *KlarnaClient, cartClient *CartClient, adyenClient *adyen.APIClient) *CheckoutPoolServer { +func NewCheckoutPoolServer(pool actor.GrainPool[checkout.CheckoutGrain], pod_name string, klarnaClient *KlarnaClient, cartClient *CartClient, adyenClient *adyen.APIClient, orderClient *OrderClient, orderHandler *AmqpOrderHandler) *CheckoutPoolServer { srv := &CheckoutPoolServer{ GrainPool: pool, pod_name: pod_name, klarnaClient: klarnaClient, cartClient: cartClient, adyenClient: adyenClient, + orderClient: orderClient, + orderHandler: orderHandler, } return srv @@ -527,14 +529,6 @@ func (s *CheckoutPoolServer) Serve(mux *http.ServeMux) { handleFunc("/payment/klarna/push", s.KlarnaPushHandler) handleFunc("/payment/klarna/notification", s.KlarnaNotificationHandler) - conn, err := amqp.Dial(amqpUrl) - if err != nil { - log.Fatalf("failed to connect to RabbitMQ: %v", err) - } - - orderHandler := NewAmqpOrderHandler(conn) - orderHandler.DefineQueue() - handleFunc("POST /api/checkout/start/{cartid}", s.StartCheckoutHandler) handleFunc("GET /api/checkout", CookieCheckoutIdHandler(s.ProxyHandler(s.GetCheckoutHandler))) handleFunc("POST /api/checkout/delivery", CookieCheckoutIdHandler(s.ProxyHandler(s.SetDeliveryHandler))) diff --git a/cmd/checkout/utils.go b/cmd/checkout/utils.go index e76da2f..899dee1 100644 --- a/cmd/checkout/utils.go +++ b/cmd/checkout/utils.go @@ -43,6 +43,16 @@ func getLocale(country string) string { return "sv-se" } +// getCountryFromCurrency is the reverse of getCurrency, used by server-to-server +// callbacks (e.g. the Adyen webhook) that carry the settled currency but not the +// shopper's host/country. +func getCountryFromCurrency(currency string) string { + if strings.EqualFold(currency, "NOK") { + return "no" + } + return "se" +} + func getCountryFromHost(host string) string { if strings.Contains(strings.ToLower(host), "-no") { return "no" diff --git a/cmd/order/handlers_checkout.go b/cmd/order/handlers_checkout.go new file mode 100644 index 0000000..e9e7706 --- /dev/null +++ b/cmd/order/handlers_checkout.go @@ -0,0 +1,196 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "sync" + "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"` +} + +// idempotencyStore guards against duplicate order creation for the same +// idempotency key. In-memory for step 1; a durable store (Redis, bolt) would +// survive restarts. The map is keyed by idempotencyKey → orderId. +var ( + idempotencyMu sync.Mutex + idempotencyKeys = map[string]uint64{} +) + +// 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 + } + + // ── Idempotency check ───────────────────────────────────────────── + if req.IdempotencyKey != "" { + idempotencyMu.Lock() + if existingID, ok := idempotencyKeys[req.IdempotencyKey]; ok { + idempotencyMu.Unlock() + // The order already exists — return it. + g, err := s.applier.Get(r.Context(), existingID) + if err != nil { + writeErr(w, http.StatusInternalServerError, fmt.Errorf("idempotent lookup: %w", err)) + return + } + writeJSON(w, http.StatusConflict, map[string]any{ + "orderId": order.OrderId(existingID).String(), + "order": g, + "existing": true, + }) + return + } + idempotencyMu.Unlock() + } + + // ── Create the order grain ──────────────────────────────────────── + id, err := order.NewOrderId() + if err != nil { + writeErr(w, http.StatusInternalServerError, fmt.Errorf("new order id: %w", err)) + return + } + ordID := uint64(id) + + // Build PlaceOrder from the same lineReq format the direct checkout uses. + po := buildFromCheckoutPlaceOrder(id, &req) + + // Build a passthrough provider for the externally-settled payment. + provider := order.NewPassthroughProvider(req.Payment.Provider, req.Payment.Reference, req.Payment.Amount) + + // Run the place-and-pay flow with the passthrough provider. Since the + // payment is already settled, this records the authorization and capture + // without any external call. + flowName := req.Flow + if flowName == "" { + flowName = s.defaultFlow + } + def, ok := s.flows.get(flowName) + if !ok { + writeErr(w, http.StatusBadRequest, fmt.Errorf("unknown flow %q", flowName)) + return + } + + // Create a per-request flow registry so the authorize/capture actions use + // the passthrough provider for this specific order. RegisterFlowActions + // registers all order lifecycle actions, overriding the default provider. + 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(r.Context(), def, st) + grain, getErr := s.applier.Get(r.Context(), ordID) + if getErr != nil { + writeErr(w, http.StatusInternalServerError, getErr) + return + } + + // On failure the flow should have compensated (voided + cancelled). Return + // the failed order with the flow trace so the checkout service can decide + // how to proceed (e.g. alert operator, retry with a new idempotency key). + status := http.StatusCreated + if runErr != nil { + status = http.StatusPaymentRequired + s.logger.Warn("from-checkout flow failed", "orderId", id.String(), "err", runErr) + } + + // Record the idempotency key so retries are safe. + if req.IdempotencyKey != "" { + idempotencyMu.Lock() + idempotencyKeys[req.IdempotencyKey] = ordID + idempotencyMu.Unlock() + } + + writeJSON(w, status, map[string]any{ + "orderId": id.String(), + "flow": res, + "order": grain, + }) +} + +// 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) + var lineTax int64 + if l.TaxRate > 0 { + // inc-vat -> tax portion = total * rate / (100 + rate) + lineTax = lineTotal * int64(l.TaxRate) / int64(100+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 +} diff --git a/cmd/order/main.go b/cmd/order/main.go index 7752f5c..15f341b 100644 --- a/cmd/order/main.go +++ b/cmd/order/main.go @@ -126,6 +126,7 @@ func main() { // Checkout + order reads. mux.HandleFunc("POST /checkout", s.handleCheckout) + mux.HandleFunc("POST /api/orders/from-checkout", s.handleFromCheckout) mux.HandleFunc("GET /api/orders", s.handleList) mux.HandleFunc("GET /api/orders/{id}", s.handleGet) mux.HandleFunc("GET /api/orders/{id}/events", s.handleEvents) diff --git a/pkg/order/passthrough.go b/pkg/order/passthrough.go new file mode 100644 index 0000000..5591f92 --- /dev/null +++ b/pkg/order/passthrough.go @@ -0,0 +1,60 @@ +package order + +import ( + "context" + "fmt" +) + +// PassthroughProvider records an already-settled authorization and capture without +// calling an external provider. It is the bridge between the checkout grain's +// externally-processed payments (Klarna, Adyen) and the order grain's event log: +// the payment was already settled by the provider, so the order grain just needs +// to record the facts. +// +// Create one per order with the exact provider name, reference and amount the +// external processor returned. Refund returns an error — use the external +// processor's own refund API for that. Void is a no-op. +type PassthroughProvider struct { + name string + reference string + amount int64 +} + +// NewPassthroughProvider returns a provider that records provider/ref/amount +// for both authorize and capture. amount is in minor currency units (öre). +func NewPassthroughProvider(providerName, reference string, amount int64) *PassthroughProvider { + return &PassthroughProvider{ + name: providerName, + reference: reference, + amount: amount, + } +} + +var _ PaymentProvider = (*PassthroughProvider)(nil) + +func (p *PassthroughProvider) Name() string { return p.name } + +func (p *PassthroughProvider) Authorize(_ context.Context, _ string, _ int64, _ string) (Authorization, error) { + return Authorization{ + Provider: p.name, + Reference: p.reference, + Amount: p.amount, + }, nil +} + +func (p *PassthroughProvider) Capture(_ context.Context, _ string, _ int64) (Capture, error) { + if p.reference == "" { + return Capture{}, fmt.Errorf("passthrough: capture requires a processor reference") + } + return Capture{ + Provider: p.name, + Reference: p.reference, + Amount: p.amount, + }, nil +} + +func (p *PassthroughProvider) Refund(_ context.Context, _ string, _ int64) (RefundResult, error) { + return RefundResult{}, fmt.Errorf("passthrough: refund not implemented — use the external provider directly") +} + +func (p *PassthroughProvider) Void(_ context.Context, _ string) error { return nil }