update orderflow
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled

This commit is contained in:
2026-06-21 23:26:37 +02:00
parent 1a365de071
commit 716a66562e
10 changed files with 651 additions and 29 deletions
+196
View File
@@ -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
}