187 lines
6.0 KiB
Go
187 lines
6.0 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"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"
|
|
"git.k6n.net/mats/platform/money"
|
|
)
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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 {
|
|
g, err := s.applier.Get(ctx, existingID)
|
|
if err != nil {
|
|
return 0, nil, nil, false, nil, fmt.Errorf("idempotent lookup: %w", err)
|
|
}
|
|
return existingID, nil, g, true, nil, nil
|
|
}
|
|
}
|
|
|
|
id, err := order.NewOrderId()
|
|
if err != nil {
|
|
return 0, nil, nil, false, nil, fmt.Errorf("new order id: %w", err)
|
|
}
|
|
ordID := uint64(id)
|
|
|
|
po := buildFromCheckoutPlaceOrder(id, req)
|
|
provider := order.NewPassthroughProvider(req.Payment.Provider, req.Payment.Reference, req.Payment.Amount)
|
|
|
|
flowName := req.Flow
|
|
if flowName == "" {
|
|
flowName = s.defaultFlow
|
|
}
|
|
def, ok := s.flows.get(flowName)
|
|
if !ok {
|
|
return 0, nil, nil, false, nil, fmt.Errorf("unknown flow %q", flowName)
|
|
}
|
|
|
|
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(ctx, def, st)
|
|
grain, getErr := s.applier.Get(ctx, ordID)
|
|
if getErr != nil {
|
|
return 0, nil, nil, false, nil, fmt.Errorf("get order grain: %w", getErr)
|
|
}
|
|
|
|
if runErr != nil {
|
|
s.logger.Warn("from-checkout flow failed", "orderId", id.String(), "err", runErr)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
return ordID, res, grain, false, runErr, nil
|
|
}
|
|
|
|
// 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)
|
|
lineTax := order.ComputeTax(money.Cents(lineTotal), int(l.TaxRate)).Int64()
|
|
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,
|
|
Location: l.Location,
|
|
})
|
|
}
|
|
po.TotalAmount = total
|
|
po.TotalTax = totalTax
|
|
return po
|
|
}
|