197 lines
6.7 KiB
Go
197 lines
6.7 KiB
Go
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
|
|
}
|