update orderflow
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user