// Command order is a single-node HTTP entrypoint for the order grain. It exposes // a simple checkout that creates an order by running the place-and-pay flow // (place -> mock authorize -> capture) on the actor framework, plus read APIs // for an order and its event timeline. No clustering, no external payment — the // simplest path to a real, event-sourced, captured order. package main import ( "context" "encoding/json" "fmt" "log" "log/slog" "net/http" "net/mail" "os" "path/filepath" "strconv" "strings" "sort" "sync" "time" "git.k6n.net/mats/go-cart-actor/internal/ucp" "git.k6n.net/mats/go-cart-actor/pkg/actor" "git.k6n.net/mats/go-cart-actor/pkg/flow" "git.k6n.net/mats/go-cart-actor/pkg/idempotency" "git.k6n.net/mats/go-cart-actor/pkg/order" ordermcp "git.k6n.net/mats/go-cart-actor/pkg/order/mcp" "git.k6n.net/mats/go-cart-actor/pkg/outbox" "git.k6n.net/mats/go-cart-actor/pkg/proxy" messages "git.k6n.net/mats/go-cart-actor/proto/order" "git.k6n.net/mats/platform/config" "git.k6n.net/mats/platform/rabbit" "git.k6n.net/mats/platform/tax" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" ) var podIp = os.Getenv("POD_IP") var name = os.Getenv("POD_NAME") type server struct { pool *actor.SimpleGrainPool[order.OrderGrain] applier *orderedApplier storage *actor.DiskStorage[order.OrderGrain] reg actor.MutationRegistry engine *flow.Engine freg *flow.Registry flows *flowStore provider order.PaymentProvider taxProvider tax.Provider defaultFlow string dataDir string idem *idempotency.Store logger *slog.Logger inventoryReservations order.InventoryReservationService } func main() { addr := config.EnvString("ORDER_ADDR", ":8092") // :8090 cart, :8091 redirector dataDir := config.EnvString("ORDER_DATA", "data/orders") flowsDir := config.EnvString("ORDER_FLOWS", "data/order-flows") if err := os.MkdirAll(dataDir, 0755); err != nil { log.Fatalf("create data dir: %v", err) } logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) reg := actor.NewMutationRegistry() order.RegisterMutations(reg) storage := actor.NewDiskStorage[order.OrderGrain](dataDir, reg) hostname := podIp if hostname == "" { hostname = "order-1" } pool, err := actor.NewSimpleGrainPool(actor.GrainPoolConfig[order.OrderGrain]{ Hostname: hostname, Spawn: func(ctx context.Context, id uint64) (actor.Grain[order.OrderGrain], error) { g := order.NewOrderGrain(id, time.Now()) // Replay any persisted history so the resident grain is current. if err := storage.LoadEvents(ctx, id, g); err != nil { return nil, err } return g, nil }, SpawnHost: func(host string) (actor.Host[order.OrderGrain], error) { return proxy.NewRemoteHost[order.OrderGrain](host) }, Destroy: func(actor.Grain[order.OrderGrain]) error { return nil }, TTL: time.Hour, PoolSize: 10000, // Storage is intentionally nil here: the pool persists mutations in a // fire-and-forget goroutine, so concurrent appends for one grain can land // out of order on disk — fatal for an event log, where replay order is the // state. We persist through orderedApplier instead (synchronous, ordered, // and only for mutations that actually applied). Replay on spawn still uses // `storage` directly in Spawn above. MutationRegistry: reg, Storage: nil, }) if err != nil { log.Fatalf("create pool: %v", err) } controlPlaneConfig := actor.DefaultServerConfig() grpcSrv, err := actor.NewControlServer[order.OrderGrain](controlPlaneConfig, pool) if err != nil { log.Fatalf("Error starting control plane gRPC server: %v\n", err) } defer grpcSrv.GracefulStop() UseDiscovery(pool) applier := &orderedApplier{pool: pool, storage: storage} provider := selectProvider(logger) // Flow registry: generic hooks + the order lifecycle actions + predicates, // paying via the selected provider. The amqp_emit hook bridges saga steps to // the broker (publisher is nil without AMQP — the hook then errors at run // time, which is logged, not fatal). amqpURL := os.Getenv("AMQP_URL") emailTemplates, err := order.LoadEmailTemplates(config.EnvString("ORDER_EMAIL_TEMPLATES", "data/order-email-templates")) if err != nil { log.Fatalf("load email templates: %v", err) } emailSender, err := selectEmailSender(logger) if err != nil { log.Fatalf("configure email sender: %v", err) } inventoryReservations, err := selectInventoryReservationService() if err != nil { log.Fatalf("configure inventory reservation service: %v", err) } var emitPub order.Publisher if amqpURL != "" { // The amqp_emit hook writes to a durable outbox rather than the broker // directly; a relay goroutine drains it to RabbitMQ (reconnecting), so an // event survives a broker outage or a crash after the state change. box, err := outbox.Open(filepath.Join(dataDir, "outbox.log")) if err != nil { // Durability-critical (same as the idempotency store): fail fast rather // than silently publish events nowhere for the whole process lifetime. log.Fatalf("open outbox: %v", err) } go box.Run(context.Background(), newRabbitPublisher(amqpURL), 5*time.Second, logger) emitPub = box // Stream applied order mutations to the shared mutations exchange (routing // key mutation.order) for the backoffice live feed. Best-effort, separate // from the durable outbox above. if conn, derr := rabbit.Dial(amqpURL, "order"); derr != nil { logger.Warn("order: mutation feed disabled", "err", derr) } else { feed := actor.NewAmqpListener(conn.Connection(), "order", actor.MutationSummary) feed.DefineTopics() pool.AddListener(feed) // Declare the "order" topic exchange the outbox relay publishes // order.created to, so a publish can't hit a missing exchange before // a consumer declares it. Durable + idempotent. if ch, cerr := conn.Channel(); cerr != nil { logger.Warn("order: declare order exchange: channel", "err", cerr) } else { if eerr := ch.ExchangeDeclare("order", "topic", true, false, false, false, nil); eerr != nil { logger.Warn("order: declare order exchange", "err", eerr) } _ = ch.Close() } } } // Email preference checker: when a profile service URL is configured, look up // customer email preferences before sending transactional emails. var prefChecker order.EmailPreferenceChecker if profileURL := os.Getenv("PROFILE_URL"); profileURL != "" { prefChecker = order.NewPreferencesChecker(profileURL + "/ucp/v1/customers") logger.Info("email preference checking enabled", "profile_url", profileURL) } freg := buildFlowRegistry(applier, provider, inventoryReservations, emitPub, emailSender, emailTemplates, prefChecker, logger) engine := flow.NewEngine(freg, logger) def, err := order.EmbeddedFlow("place-and-pay") if err != nil { log.Fatalf("load flow: %v", err) } fulfillDef, err := order.EmbeddedFlow("fulfill-order") if err != nil { log.Fatalf("load fulfill flow: %v", err) } flows, err := newFlowStore(flowsDir, engine, map[string]*flow.Definition{ def.Name: def, fulfillDef.Name: fulfillDef, }) if err != nil { log.Fatalf("load flows: %v", err) } // Durable idempotency store for order creation — survives a restart, so a // client retry after a crash returns the existing order instead of creating // a duplicate. idem, err := idempotency.Open(filepath.Join(dataDir, "idempotency.log")) if err != nil { log.Fatalf("open idempotency store: %v", err) } taxProvider := selectTaxProvider() s := &server{ pool: pool, applier: applier, storage: storage, reg: reg, engine: engine, freg: freg, flows: flows, provider: provider, taxProvider: taxProvider, defaultFlow: def.Name, dataDir: dataDir, idem: idem, logger: logger, inventoryReservations: inventoryReservations, } mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) // 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/stats", s.handleStats) mux.HandleFunc("GET /api/orders/{id}", s.handleGet) mux.HandleFunc("GET /api/orders/{id}/events", s.handleEvents) // Post-purchase lifecycle (the grain already supports these transitions). mux.HandleFunc("POST /api/orders/{id}/fulfillments", s.handleFulfill) mux.HandleFunc("POST /api/orders/{id}/complete", s.handleComplete) mux.HandleFunc("POST /api/orders/{id}/cancel", s.handleCancel) mux.HandleFunc("POST /api/orders/{id}/returns", s.handleReturn) mux.HandleFunc("POST /api/orders/{id}/refunds", s.handleRefund) mux.HandleFunc("POST /api/orders/{id}/exchanges", s.handleExchange) mux.HandleFunc("POST /api/orders/{id}/edit", s.handleEditDetails) // UCP REST adapter — Universal Commerce Protocol order lifecycle. orderUCP := ucp.OrderHandler(s.applier) if signer := loadUCPOrderSigner(); signer != nil { orderUCP = ucp.WithSigning(orderUCP, signer) logger.Info("ucp signing enabled") } // StripPrefix is required because the sub-mux (OrderHandler) registers // routes like "GET /{id}" which expect a relative path; Go's ServeMux // passes the full request path without stripping the prefix. // Only the subtree pattern is registered (see comment in cmd/cart/main.go // for why the exact-match pattern is omitted). mux.Handle("/ucp/v1/orders/", http.StripPrefix("/ucp/v1/orders", orderUCP)) // Order MCP streamable-HTTP server (inspect + mutate orders via tools). // Mounted at /order-mcp to avoid conflicting with the backoffice CMS MCP at // /mcp (which is routed by the edge). orderMCP := ordermcp.New(applier) mux.Handle("/order-mcp", orderMCP.Handler()) mux.Handle("/order-mcp/", orderMCP.Handler()) // Saga / flow admin (backoffice editor). mux.HandleFunc("GET /sagas/capabilities", s.handleCapabilities) mux.HandleFunc("GET /sagas/flows", s.handleListFlows) mux.HandleFunc("GET /sagas/flows/{name}", s.handleGetFlow) mux.HandleFunc("PUT /sagas/flows/{name}", s.handleSaveFlow) // Email template admin API (used by the backoffice Email Template Manager). eth := newEmailTemplateHandler(config.EnvString("ORDER_EMAIL_TEMPLATES", "data/order-email-templates")) mux.HandleFunc("GET /api/order-email-templates", eth.list) mux.HandleFunc("GET /api/order-email-templates/{name}", eth.get) mux.HandleFunc("PUT /api/order-email-templates/{name}", eth.save) mux.HandleFunc("DELETE /api/order-email-templates/{name}", eth.delete) mux.HandleFunc("POST /api/order-email-templates/{name}/preview", eth.preview) // Ingest checkout orders from order-queue (Klarna/Adyen fallback). if amqpURL != "" { startOrderIngest(context.Background(), amqpURL, s) } logger.Info("order service listening", "addr", addr, "data", dataDir, "flows", flowsDir) if err := http.ListenAndServe(addr, mux); err != nil { log.Fatalf("serve: %v", err) } } // orderedApplier wraps the grain pool to persist the event log correctly: it // appends each applied mutation synchronously and in call order (the pool's own // storage path is async/unordered), and only persists mutations whose handler // succeeded — a rejected transition must never enter the log. A single mutex // serialises all appends, which is ample for a single-node checkout service. type orderedApplier struct { pool *actor.SimpleGrainPool[order.OrderGrain] storage *actor.DiskStorage[order.OrderGrain] mu sync.Mutex } func (a *orderedApplier) Get(ctx context.Context, id uint64) (*order.OrderGrain, error) { return a.pool.Get(ctx, id) } func (a *orderedApplier) Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[order.OrderGrain], error) { res, err := a.pool.Apply(ctx, id, mutation...) if err != nil || res == nil { return res, err } // Persist only the mutations that actually applied (result index aligns with // the input order). applied := make([]proto.Message, 0, len(mutation)) for i, m := range mutation { if i < len(res.Mutations) && res.Mutations[i].Error != nil { continue } applied = append(applied, m) } if len(applied) > 0 { a.mu.Lock() appendErr := a.storage.AppendMutations(id, applied...) a.mu.Unlock() if appendErr != nil { return res, appendErr } } return res, nil } // --- checkout ------------------------------------------------------------- type lineReq struct { Reference string `json:"reference"` Sku string `json:"sku"` Name string `json:"name"` Quantity int32 `json:"quantity"` UnitPrice int64 `json:"unitPrice"` // inc-vat, minor units TaxRate int32 `json:"taxRate"` // basis points (2500 = 25%) DropShip bool `json:"drop_ship,omitempty"` Location string `json:"location,omitempty"` // inventory commit location / store id } type checkoutReq struct { OrderReference string `json:"orderReference,omitempty"` CartId string `json:"cartId,omitempty"` Currency string `json:"currency,omitempty"` Locale string `json:"locale,omitempty"` Country string `json:"country,omitempty"` CustomerEmail string `json:"customerEmail,omitempty"` CustomerName string `json:"customerName,omitempty"` BillingAddress json.RawMessage `json:"billingAddress,omitempty"` ShippingAddress json.RawMessage `json:"shippingAddress,omitempty"` Lines []lineReq `json:"lines"` } func (s *server) handleCheckout(w http.ResponseWriter, r *http.Request) { var req checkoutReq 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("checkout requires at least one line")) return } id, err := order.NewOrderId() if err != nil { writeErr(w, http.StatusInternalServerError, err) return } if req.OrderReference == "" { req.OrderReference = "ord-" + id.String() } if req.Currency == "" { req.Currency = "SEK" } flowName := r.URL.Query().Get("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 } po := s.buildPlaceOrder(id, &req) st := flow.NewState(uint64(id), s.logger) st.Vars[order.PlaceOrderVar] = po res, runErr := s.engine.Run(r.Context(), def, st) grain, getErr := s.pool.Get(r.Context(), uint64(id)) if getErr != nil { writeErr(w, http.StatusInternalServerError, getErr) return } status := http.StatusCreated if runErr != nil { // The flow compensated; the order exists but is not paid. Report 402 with // the (now cancelled/failed) order and the flow trace so the caller knows. status = http.StatusPaymentRequired s.logger.Warn("checkout flow failed", "orderId", id.String(), "err", runErr) } writeJSON(w, status, map[string]any{ "orderId": id.String(), "flow": res, "order": grain, }) } // buildPlaceOrder converts a checkout request into the PlaceOrder event, // computing per-line and order totals (inc-vat) from the lines. // Tax amounts are computed using the configured TaxProvider. func (s *server) buildPlaceOrder(id order.OrderId, req *checkoutReq) *messages.PlaceOrder { po := &messages.PlaceOrder{ OrderReference: req.OrderReference, CartId: req.CartId, Currency: req.Currency, Locale: req.Locale, Country: req.Country, CustomerEmail: req.CustomerEmail, CustomerName: req.CustomerName, BillingAddress: req.BillingAddress, ShippingAddress: req.ShippingAddress, PlacedAtMs: time.Now().UnixMilli(), } var total, totalTax int64 for _, l := range req.Lines { lineTotal := l.UnitPrice * int64(l.Quantity) lineTax := s.taxProvider.Compute(lineTotal, int(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, DropShip: l.DropShip, }) } po.TotalAmount = total po.TotalTax = totalTax return po } // --- dashboard stats ------------------------------------------------------ type orderAlert struct { Severity string `json:"severity"` // warn | error Message string `json:"message"` OrderId string `json:"orderId,omitempty"` } type statusBucket struct { Status order.Status `json:"status"` Count int `json:"count"` Total int64 `json:"total"` } type revenueSnapshot struct { Total int64 `json:"total"` Captured int64 `json:"captured"` Refunded int64 `json:"refunded"` Pending int64 `json:"pending"` } type dailyRevenue struct { Date string `json:"date"` // YYYY-MM-DD Total int64 `json:"total"` } type orderStatsResponse struct { OrdersTotal int `json:"ordersTotal"` Revenue revenueSnapshot `json:"revenue"` ByStatus []statusBucket `json:"byStatus"` RecentOrders []orderSummary `json:"recentOrders"` DailyRevenue30 []dailyRevenue `json:"dailyRevenue30,omitempty"` Alerts []orderAlert `json:"alerts"` } // handleStats scans all order logs and returns aggregated dashboard stats. func (s *server) handleStats(w http.ResponseWriter, r *http.Request) { matches, _ := filepath.Glob(filepath.Join(s.dataDir, "*.events.log")) now := time.Now() var totalOrders int var totalRevenue, capturedRevenue, refundedRevenue int64 byStatus := map[order.Status]int{} statusTotal := map[order.Status]int64{} // total amount per status // Daily revenue for the last 30 days (keys are "2026-06-01" sortable strings). dailyByDay := map[string]int64{} for i := 0; i < 30; i++ { day := now.AddDate(0, 0, -i).Format("2006-01-02") dailyByDay[day] = 0 } // Collect all order summaries, then sort by placedAt and take top 10. var allOrders []orderSummary var alerts []orderAlert for _, m := range matches { base := strings.TrimSuffix(filepath.Base(m), ".events.log") raw, err := strconv.ParseUint(base, 10, 64) if err != nil { continue } g, err := s.pool.Get(r.Context(), raw) if err != nil || g.Status == order.StatusNew { continue } totalOrders++ byStatus[g.Status]++ statusTotal[g.Status] += g.TotalAmount.Int64() totalRevenue += g.TotalAmount.Int64() capturedRevenue += g.CapturedAmount.Int64() refundedRevenue += g.RefundedAmount.Int64() // Daily revenue — parse placedAt to extract date. if g.PlacedAt != "" { if placed, parseErr := time.Parse(time.RFC3339, g.PlacedAt); parseErr == nil { dayKey := placed.Format("2006-01-02") if _, ok := dailyByDay[dayKey]; ok { dailyByDay[dayKey] += g.TotalAmount.Int64() } } } allOrders = append(allOrders, orderSummary{ OrderId: order.OrderId(raw).String(), Reference: g.OrderReference, Status: g.Status, TotalAmount: g.TotalAmount.Int64(), Currency: g.Currency, PlacedAt: g.PlacedAt, }) // Alerts. if g.Status == order.StatusPending { alerts = append(alerts, orderAlert{ Severity: "warn", Message: "Payment still pending", OrderId: order.OrderId(raw).String(), }) } if g.Status == order.StatusCancelled { alerts = append(alerts, orderAlert{ Severity: "warn", Message: "Order was cancelled", OrderId: order.OrderId(raw).String(), }) } } // Sort ALL orders by placedAt descending, take top 10 as "recent". sort.Slice(allOrders, func(i, j int) bool { return allOrders[i].PlacedAt > allOrders[j].PlacedAt }) recent := allOrders if len(recent) > 10 { recent = recent[:10] } // Cap alerts if len(alerts) > 50 { alerts = alerts[:50] } // Build status buckets array with totals. buckets := make([]statusBucket, 0, len(byStatus)) for st, cnt := range byStatus { buckets = append(buckets, statusBucket{Status: st, Count: cnt, Total: statusTotal[st]}) } sort.Slice(buckets, func(i, j int) bool { return buckets[i].Count > buckets[j].Count }) // Daily revenue as an ordered slice. var dailyRev []dailyRevenue for i := 29; i >= 0; i-- { day := now.AddDate(0, 0, -i).Format("2006-01-02") dailyRev = append(dailyRev, dailyRevenue{Date: day, Total: dailyByDay[day]}) } writeJSON(w, http.StatusOK, orderStatsResponse{ OrdersTotal: totalOrders, Revenue: revenueSnapshot{ Total: totalRevenue, Captured: capturedRevenue, Refunded: refundedRevenue, Pending: totalRevenue - capturedRevenue - refundedRevenue, }, ByStatus: buckets, RecentOrders: recent, DailyRevenue30: dailyRev, Alerts: alerts, }) } // --- reads ---------------------------------------------------------------- func (s *server) handleGet(w http.ResponseWriter, r *http.Request) { id, ok := order.ParseOrderId(r.PathValue("id")) if !ok { writeErr(w, http.StatusBadRequest, fmt.Errorf("invalid order id")) return } grain, err := s.pool.Get(r.Context(), uint64(id)) if err != nil { writeErr(w, http.StatusInternalServerError, err) return } if grain.Status == order.StatusNew { writeErr(w, http.StatusNotFound, fmt.Errorf("order not found")) return } writeJSON(w, http.StatusOK, map[string]any{"orderId": id.String(), "order": grain}) } type eventView struct { Type string `json:"type"` Time string `json:"time,omitempty"` Payload json.RawMessage `json:"payload,omitempty"` } // handleEvents returns the raw event timeline by reading the grain's append-only // log (the source of truth) without re-applying it to live state. func (s *server) handleEvents(w http.ResponseWriter, r *http.Request) { id, ok := order.ParseOrderId(r.PathValue("id")) if !ok { writeErr(w, http.StatusBadRequest, fmt.Errorf("invalid order id")) return } var events []eventView throwaway := order.NewOrderGrain(uint64(id), time.Now()) err := s.storage.LoadEventsFunc(r.Context(), uint64(id), throwaway, func(msg proto.Message, _ int, ts time.Time) bool { name, _ := s.reg.GetTypeName(msg) payload, _ := protojson.Marshal(msg) events = append(events, eventView{Type: name, Time: ts.UTC().Format(time.RFC3339), Payload: payload}) return false // collect only; do not apply }) if err != nil { writeErr(w, http.StatusInternalServerError, err) return } writeJSON(w, http.StatusOK, map[string]any{"orderId": id.String(), "events": events}) } type orderSummary struct { OrderId string `json:"orderId"` Reference string `json:"reference,omitempty"` Status order.Status `json:"status"` TotalAmount int64 `json:"totalAmount"` CapturedAmount int64 `json:"capturedAmount"` Currency string `json:"currency,omitempty"` PlacedAt string `json:"placedAt,omitempty"` } // handleList scans the data dir for order logs and summarises each (replaying it // through the pool). Fine for a single-node simple checkout; a production list // would use an index/projection store. func (s *server) handleList(w http.ResponseWriter, r *http.Request) { matches, _ := filepath.Glob(filepath.Join(s.dataDir, "*.events.log")) out := make([]orderSummary, 0, len(matches)) for _, m := range matches { base := strings.TrimSuffix(filepath.Base(m), ".events.log") raw, err := strconv.ParseUint(base, 10, 64) if err != nil { continue } g, err := s.pool.Get(r.Context(), raw) if err != nil || g.Status == order.StatusNew { continue } out = append(out, orderSummary{ OrderId: order.OrderId(raw).String(), Reference: g.OrderReference, Status: g.Status, TotalAmount: g.TotalAmount.Int64(), CapturedAmount: g.CapturedAmount.Int64(), Currency: g.Currency, PlacedAt: g.PlacedAt, }) } writeJSON(w, http.StatusOK, out) } // --- helpers -------------------------------------------------------------- // selectTaxProvider picks the tax provider from the environment. Default is // NordicTaxProvider with SE as the default country (matching the current // Nordic-only footprint). Set TAX_PROVIDER=static for dev and tests // (no country awareness). func selectTaxProvider() tax.Provider { provider := os.Getenv("TAX_PROVIDER") switch provider { case "static": return tax.NewStatic() case "nordic": return tax.NewNordic(config.EnvString("TAX_DEFAULT_COUNTRY", "SE")) default: return tax.NewNordic(config.EnvString("TAX_DEFAULT_COUNTRY", "SE")) } } // selectProvider picks the payment provider from the environment. Default is // the dependency-free mock; set PAYMENT_PROVIDER=stripe + STRIPE_SECRET_KEY for // real Stripe (STRIPE_API_BASE / STRIPE_PAYMENT_METHOD optionally override). func selectProvider(logger *slog.Logger) order.PaymentProvider { if os.Getenv("PAYMENT_PROVIDER") == "stripe" { key := os.Getenv("STRIPE_SECRET_KEY") if key == "" { logger.Warn("PAYMENT_PROVIDER=stripe but STRIPE_SECRET_KEY is empty; falling back to mock") return order.NewMockProvider() } logger.Info("using stripe payment provider") return order.NewStripeProvider(key, os.Getenv("STRIPE_API_BASE"), os.Getenv("STRIPE_PAYMENT_METHOD")) } return order.NewMockProvider() } // selectEmailSender picks the flow-email transport. Supports SMTP and // MailerSend. When no sender is configured the hook still exists in // capabilities but errors at run time if a flow tries to use it. func selectEmailSender(logger *slog.Logger) (order.EmailSender, error) { kind := config.EnvString("ORDER_EMAIL_SENDER", "") if kind == "" && os.Getenv("ORDER_EMAIL_SMTP_HOST") == "" { return nil, nil } if kind == "" { kind = "smtp" } fromAddr := strings.TrimSpace(os.Getenv("ORDER_EMAIL_FROM_ADDRESS")) if fromAddr == "" { return nil, fmt.Errorf("ORDER_EMAIL_FROM_ADDRESS is required when email sender is configured") } from := mail.Address{ Name: strings.TrimSpace(os.Getenv("ORDER_EMAIL_FROM_NAME")), Address: fromAddr, } switch kind { case "smtp": sender, err := order.NewSMTPEmailSender(order.SMTPEmailSenderConfig{ Host: config.EnvString("ORDER_EMAIL_SMTP_HOST", ""), Port: config.EnvInt("ORDER_EMAIL_SMTP_PORT", 587, func(n int) bool { return n > 0 }), Username: os.Getenv("ORDER_EMAIL_SMTP_USERNAME"), Password: os.Getenv("ORDER_EMAIL_SMTP_PASSWORD"), DefaultFrom: from, Timeout: config.EnvDuration("ORDER_EMAIL_SMTP_TIMEOUT", 10*time.Second), }) if err != nil { return nil, err } logger.Info("email sender enabled", "kind", "smtp", "host", config.EnvString("ORDER_EMAIL_SMTP_HOST", ""), "from", from.Address) return sender, nil case "mailersend": apiKey := os.Getenv("MAILERSEND_API_KEY") if apiKey == "" { return nil, fmt.Errorf("MAILERSEND_API_KEY is required for mailersend sender") } sender, err := order.NewMailerSendEmailSender(apiKey, from) if err != nil { return nil, err } logger.Info("email sender enabled", "kind", "mailersend", "from", from.Address) return sender, nil default: return nil, fmt.Errorf("unsupported ORDER_EMAIL_SENDER %q (supported: smtp, mailersend)", kind) } } func selectInventoryReservationService() (order.InventoryReservationService, error) { return order.NewHTTPInventoryReservationService(config.EnvString("INVENTORY_URL", "http://localhost:8080"), nil) } // loadUCPOrderSigner loads the UCP ECDSA signing key from the path specified in // the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable. func loadUCPOrderSigner() *ucp.SigningConfig { path := os.Getenv("UCP_SIGNING_KEY_PATH") if path == "" { return nil } pemData, err := os.ReadFile(path) if err != nil { log.Printf("ucp signing: cannot read key %s: %v", path, err) return nil } cfg, err := ucp.NewSigningConfigFromPEM(pemData, "k6n-ecdsa-2026") if err != nil { log.Printf("ucp signing: invalid key at %s: %v", path, err) return nil } return cfg } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func writeErr(w http.ResponseWriter, status int, err error) { writeJSON(w, status, map[string]string{"error": err.Error()}) }