cart and checkout
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s

This commit is contained in:
2026-06-27 19:49:00 +02:00
parent 492f54ff45
commit 528c59bfd3
67 changed files with 3618 additions and 1031 deletions
+46 -55
View File
@@ -1,6 +1,7 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
@@ -18,7 +19,7 @@ import (
type fromCheckoutReq struct {
CheckoutId string `json:"checkoutId"`
IdempotencyKey string `json:"idempotencyKey"`
Flow string `json:"flow,omitempty"`
Flow string `json:"flow,omitempty"`
CartId string `json:"cartId"`
Currency string `json:"currency"`
@@ -55,101 +56,95 @@ func (s *server) handleFromCheckout(w http.ResponseWriter, r *http.Request) {
return
}
// ── Idempotency check ─────────────────────────────────────────────
// Lock the key for the whole check→create→record sequence so a concurrent
// retry with the same key can't race past the check and create a second
// order. The store is durable, so this also holds across a restart.
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 {
// The order already exists — return it.
g, err := s.applier.Get(r.Context(), existingID)
g, err := s.applier.Get(ctx, existingID)
if err != nil {
writeErr(w, http.StatusInternalServerError, fmt.Errorf("idempotent lookup: %w", err))
return
return 0, nil, nil, false, nil, fmt.Errorf("idempotent lookup: %w", err)
}
writeJSON(w, http.StatusConflict, map[string]any{
"orderId": order.OrderId(existingID).String(),
"order": g,
"existing": true,
})
return
return existingID, nil, g, true, nil, nil
}
}
// ── Create the order grain ────────────────────────────────────────
id, err := order.NewOrderId()
if err != nil {
writeErr(w, http.StatusInternalServerError, fmt.Errorf("new order id: %w", err))
return
return 0, nil, nil, false, nil, fmt.Errorf("new order id: %w", err)
}
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.
po := buildFromCheckoutPlaceOrder(id, req)
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
return 0, nil, nil, false, nil, fmt.Errorf("unknown flow %q", flowName)
}
// 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)
res, runErr := engine.Run(ctx, def, st)
grain, getErr := s.applier.Get(ctx, ordID)
if getErr != nil {
writeErr(w, http.StatusInternalServerError, getErr)
return
return 0, nil, nil, false, nil, fmt.Errorf("get order grain: %w", getErr)
}
// 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 durably only on success, so retries (incl. after
// a restart) return the captured order. A failed flow (402) is deliberately
// NOT recorded: the failed grain is terminal, and a retry with the same key
// should re-attempt rather than be handed back a dead order as a 409 hit
// (which the checkout client treats as a successful, existing order).
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)
}
}
writeJSON(w, status, map[string]any{
"orderId": id.String(),
"flow": res,
"order": grain,
})
return ordID, res, grain, false, runErr, nil
}
// buildFromCheckoutPlaceOrder converts a from-checkout request into a PlaceOrder
@@ -169,11 +164,7 @@ func buildFromCheckoutPlaceOrder(id order.OrderId, req *fromCheckoutReq) *messag
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)
}
lineTax := order.ComputeTax(lineTotal, int(l.TaxRate))
total += lineTotal
totalTax += lineTax
po.Lines = append(po.Lines, &messages.OrderLine{