refactor
This commit is contained in:
@@ -10,8 +10,8 @@ import (
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/idempotency"
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -41,10 +41,7 @@ func testServer(t *testing.T) *server {
|
||||
applier := &orderedApplier{pool: pool, storage: storage}
|
||||
provider := order.NewPassthroughProvider("legacy", "ref", 15000)
|
||||
|
||||
freg := flow.NewRegistry()
|
||||
flow.RegisterBuiltinHooks(freg)
|
||||
order.RegisterFlowActions(freg, applier, provider)
|
||||
order.RegisterEmitHook(freg, nil)
|
||||
freg := buildFlowRegistry(applier, provider, nil, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
engine := flow.NewEngine(freg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
|
||||
def, err := order.EmbeddedFlow("place-and-pay")
|
||||
@@ -60,7 +57,7 @@ func testServer(t *testing.T) *server {
|
||||
return &server{
|
||||
pool: pool, applier: applier, storage: storage, reg: reg,
|
||||
engine: engine, freg: freg,
|
||||
flows: &flowStore{defs: map[string]*flow.Definition{def.Name: def}},
|
||||
flows: &flowStore{defs: map[string]*flow.Definition{def.Name: def}},
|
||||
provider: provider, taxProvider: order.NewStaticTaxProvider(),
|
||||
defaultFlow: def.Name, idem: idem, logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
||||
)
|
||||
|
||||
type projectFulfillmentHookParams struct {
|
||||
Integration string `json:"integration,omitempty"`
|
||||
Target string `json:"target,omitempty"`
|
||||
Dropship bool `json:"dropship,omitempty"`
|
||||
}
|
||||
|
||||
// registerProjectFlowHooks is the project-layer seam for customer-specific flow
|
||||
// integrations. These belong here rather than pkg/order so core order logic
|
||||
// stays generic while deployments can register their own fulfillment tools.
|
||||
func registerProjectFlowHooks(reg *flow.Registry, app *orderedApplier, logger *slog.Logger) {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
reg.HookWithMeta("project_fulfillment_integration", func(ctx context.Context, st *flow.State, info flow.HookInfo, params json.RawMessage) error {
|
||||
var p projectFulfillmentHookParams
|
||||
if len(params) > 0 {
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return fmt.Errorf("project_fulfillment_integration: bad params: %w", err)
|
||||
}
|
||||
}
|
||||
if p.Integration == "" {
|
||||
p.Integration = "order-integration"
|
||||
}
|
||||
|
||||
o, err := app.Get(ctx, st.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(o.Fulfillments) == 0 {
|
||||
return fmt.Errorf("project_fulfillment_integration: order %s has no fulfillment yet", order.OrderId(st.ID).String())
|
||||
}
|
||||
last := o.Fulfillments[len(o.Fulfillments)-1]
|
||||
|
||||
log := st.Logger
|
||||
if log == nil {
|
||||
log = logger
|
||||
}
|
||||
log.Info("project fulfillment integration",
|
||||
"orderId", order.OrderId(st.ID).String(),
|
||||
"orderReference", o.OrderReference,
|
||||
"customerEmail", o.CustomerEmail,
|
||||
"integration", p.Integration,
|
||||
"target", p.Target,
|
||||
"dropship", p.Dropship,
|
||||
"step", info.Step,
|
||||
"phase", info.Phase,
|
||||
"fulfillmentId", last.ID,
|
||||
"carrier", last.Carrier,
|
||||
"trackingNumber", last.TrackingNumber,
|
||||
)
|
||||
return nil
|
||||
}, flow.CapabilityMeta{
|
||||
Description: "Project-specific fulfillment integration seam for local or testing adapters.",
|
||||
ExampleParams: json.RawMessage(`{"integration":"dropship-test","target":"erp-sandbox","dropship":true}`),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestProjectFulfillmentIntegrationHookIsRegistered(t *testing.T) {
|
||||
s := testServer(t)
|
||||
if !strings.Contains(strings.Join(s.freg.Capabilities().Hooks, ","), "project_fulfillment_integration") {
|
||||
t.Fatalf("hooks = %v, want project_fulfillment_integration", s.freg.Capabilities().Hooks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectFulfillmentIntegrationLogs(t *testing.T) {
|
||||
s := testServer(t)
|
||||
var logs bytes.Buffer
|
||||
logger := slog.New(slog.NewJSONHandler(&logs, nil))
|
||||
|
||||
provider := order.NewPassthroughProvider("legacy", "ref", 25000)
|
||||
freg := buildFlowRegistry(s.applier, provider, nil, nil, nil, nil, logger)
|
||||
engine := flow.NewEngine(freg, logger)
|
||||
|
||||
st := flow.NewState(801, logger)
|
||||
po := orderTestPlaceMsg()
|
||||
po.CustomerEmail = "dropship@example.com"
|
||||
st.Vars[order.PlaceOrderVar] = po
|
||||
|
||||
def, err := order.EmbeddedFlow("place-and-pay")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := engine.Run(context.Background(), def, st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ship := &flow.Definition{Name: "ship", Steps: []flow.Step{{
|
||||
Name: "fulfill",
|
||||
Action: "create_fulfillment",
|
||||
Params: json.RawMessage(`{"carrier":"dropshipper","trackingNumber":"DS-123","lines":[{"reference":"l1","quantity":1}]}`),
|
||||
Hooks: flow.Hooks{After: []flow.HookRef{{
|
||||
Type: "project_fulfillment_integration",
|
||||
Params: json.RawMessage(`{"integration":"dropship-test","target":"erp-sandbox","dropship":true}`),
|
||||
}}},
|
||||
}}}
|
||||
if _, err := engine.Run(context.Background(), ship, flow.NewState(801, logger)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := logs.String()
|
||||
for _, want := range []string{
|
||||
`"msg":"project fulfillment integration"`,
|
||||
`"integration":"dropship-test"`,
|
||||
`"target":"erp-sandbox"`,
|
||||
`"dropship":true`,
|
||||
`"trackingNumber":"DS-123"`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("log output missing %s: %s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func orderTestPlaceMsg() *messages.PlaceOrder {
|
||||
return &messages.PlaceOrder{
|
||||
OrderReference: "ref-1",
|
||||
CartId: "cart-1",
|
||||
Currency: "SEK",
|
||||
Country: "SE",
|
||||
CustomerName: "Dropship Customer",
|
||||
TotalAmount: 25000,
|
||||
TotalTax: 5000,
|
||||
PlacedAtMs: 1718877600000,
|
||||
Lines: []*messages.OrderLine{
|
||||
{Reference: "l1", Sku: "ABC", Name: "Widget", Quantity: 2, UnitPrice: 10000, TaxRate: 25, TotalAmount: 20000},
|
||||
{Reference: "l2", Sku: "DEF", Name: "Gizmo", Quantity: 1, UnitPrice: 5000, TaxRate: 25, TotalAmount: 5000},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
||||
)
|
||||
|
||||
func buildFlowRegistry(
|
||||
applier *orderedApplier,
|
||||
provider order.PaymentProvider,
|
||||
inventoryReservations order.InventoryReservationService,
|
||||
emitPub order.Publisher,
|
||||
emailSender order.EmailSender,
|
||||
emailTemplates order.EmailTemplateStore,
|
||||
logger *slog.Logger,
|
||||
) *flow.Registry {
|
||||
freg := flow.NewRegistry()
|
||||
flow.RegisterBuiltinHooks(freg)
|
||||
order.RegisterFlowActions(freg, applier, provider)
|
||||
order.RegisterInventoryReservationActions(freg, applier, inventoryReservations)
|
||||
order.RegisterEmitHook(freg, emitPub)
|
||||
order.RegisterEmailHook(freg, applier, emailSender, emailTemplates)
|
||||
order.RegisterFulfillmentWebhookHook(freg, applier, nil)
|
||||
order.RegisterOrderCreatedEmit(freg, applier, emitPub, "order", "order")
|
||||
registerProjectFlowHooks(freg, applier, logger)
|
||||
return freg
|
||||
}
|
||||
@@ -18,9 +18,9 @@ import (
|
||||
// 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"`
|
||||
CheckoutId string `json:"checkoutId"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
Flow string `json:"flow,omitempty"`
|
||||
|
||||
CartId string `json:"cartId"`
|
||||
Currency string `json:"currency"`
|
||||
@@ -120,11 +120,7 @@ func (s *server) createOrderFromCheckoutInternal(ctx context.Context, req *fromC
|
||||
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)
|
||||
|
||||
freg := buildFlowRegistry(s.applier, provider, s.inventoryReservations, nil, nil, nil, s.logger)
|
||||
engine := flow.NewEngine(freg, s.logger)
|
||||
st := flow.NewState(ordID, s.logger)
|
||||
st.Vars[order.PlaceOrderVar] = po
|
||||
@@ -178,6 +174,7 @@ func buildFromCheckoutPlaceOrder(id order.OrderId, req *fromCheckoutReq) *messag
|
||||
TotalAmount: lineTotal,
|
||||
TotalTax: lineTax,
|
||||
Location: l.Location,
|
||||
DropShip: l.DropShip,
|
||||
})
|
||||
}
|
||||
po.TotalAmount = total
|
||||
|
||||
+75
-23
@@ -12,6 +12,7 @@ import (
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -36,19 +37,20 @@ import (
|
||||
)
|
||||
|
||||
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
|
||||
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() {
|
||||
@@ -101,6 +103,18 @@ func main() {
|
||||
// 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
|
||||
@@ -138,13 +152,7 @@ func main() {
|
||||
}
|
||||
}
|
||||
}
|
||||
freg := flow.NewRegistry()
|
||||
flow.RegisterBuiltinHooks(freg)
|
||||
order.RegisterFlowActions(freg, applier, provider)
|
||||
order.RegisterEmitHook(freg, emitPub)
|
||||
// order.created domain event → durable outbox → "order" topic exchange.
|
||||
// Reactors: inventory commit, fulfillment allocation.
|
||||
order.RegisterOrderCreatedEmit(freg, applier, emitPub, "order", "order")
|
||||
freg := buildFlowRegistry(applier, provider, inventoryReservations, emitPub, emailSender, emailTemplates, logger)
|
||||
engine := flow.NewEngine(freg, logger)
|
||||
|
||||
def, err := order.EmbeddedFlow("place-and-pay")
|
||||
@@ -171,6 +179,7 @@ func main() {
|
||||
engine: engine, freg: freg, flows: flows, provider: provider,
|
||||
taxProvider: taxProvider,
|
||||
defaultFlow: def.Name, dataDir: dataDir, idem: idem, logger: logger,
|
||||
inventoryReservations: inventoryReservations,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
@@ -276,8 +285,9 @@ type lineReq struct {
|
||||
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%)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -384,6 +394,7 @@ func (s *server) buildPlaceOrder(id order.OrderId, req *checkoutReq) *messages.P
|
||||
TaxRate: l.TaxRate,
|
||||
TotalAmount: lineTotal,
|
||||
TotalTax: lineTax,
|
||||
DropShip: l.DropShip,
|
||||
})
|
||||
}
|
||||
po.TotalAmount = total
|
||||
@@ -502,7 +513,6 @@ func selectTaxProvider() tax.Provider {
|
||||
// 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 == "" {
|
||||
@@ -515,6 +525,48 @@ func selectProvider(logger *slog.Logger) order.PaymentProvider {
|
||||
return order.NewMockProvider()
|
||||
}
|
||||
|
||||
// selectEmailSender picks the flow-email transport. SMTP is the only sender
|
||||
// implementation for now; when no SMTP host 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"
|
||||
}
|
||||
if kind != "smtp" {
|
||||
return nil, fmt.Errorf("unsupported ORDER_EMAIL_SENDER %q", kind)
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
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", kind, "host", config.EnvString("ORDER_EMAIL_SMTP_HOST", ""), "from", from.Address)
|
||||
return sender, nil
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user