refactor
This commit is contained in:
+81
-20
@@ -64,7 +64,7 @@ const PlaceOrderVar = "placeOrder"
|
||||
// grain through app and taking payment via provider. Compose with
|
||||
// flow.RegisterBuiltinHooks(reg) to get the "log"/"webhook" hooks too.
|
||||
func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvider) {
|
||||
reg.Action("place_order", func(ctx context.Context, st *flow.State, _ json.RawMessage) error {
|
||||
reg.ActionWithMeta("place_order", func(ctx context.Context, st *flow.State, _ json.RawMessage) error {
|
||||
po, ok := st.Vars[PlaceOrderVar].(*messages.PlaceOrder)
|
||||
if !ok || po == nil {
|
||||
return fmt.Errorf("place_order: flow var %q is not a *PlaceOrder", PlaceOrderVar)
|
||||
@@ -78,9 +78,9 @@ func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvid
|
||||
st.Vars["currency"] = po.GetCurrency()
|
||||
st.Vars["orderReference"] = po.GetOrderReference()
|
||||
return nil
|
||||
})
|
||||
}, flow.CapabilityMeta{Description: "Create the order from the flow state variable `placeOrder`."})
|
||||
|
||||
reg.Action("authorize_payment", func(ctx context.Context, st *flow.State, _ json.RawMessage) error {
|
||||
reg.ActionWithMeta("authorize_payment", func(ctx context.Context, st *flow.State, _ json.RawMessage) error {
|
||||
o, err := app.Get(ctx, st.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -101,9 +101,9 @@ func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvid
|
||||
st.Vars["authProvider"] = auth.Provider
|
||||
st.Vars["authAmount"] = auth.Amount
|
||||
return nil
|
||||
})
|
||||
}, flow.CapabilityMeta{Description: "Authorize payment with the configured provider."})
|
||||
|
||||
reg.Action("capture_payment", func(ctx context.Context, st *flow.State, _ json.RawMessage) error {
|
||||
reg.ActionWithMeta("capture_payment", func(ctx context.Context, st *flow.State, _ json.RawMessage) error {
|
||||
authRef, _ := st.Vars["authRef"].(string)
|
||||
amount, _ := st.Vars["authAmount"].(int64)
|
||||
if amount == 0 {
|
||||
@@ -121,11 +121,11 @@ func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvid
|
||||
Reference: capture.Reference,
|
||||
AtMs: nowMs(),
|
||||
})
|
||||
})
|
||||
}, flow.CapabilityMeta{Description: "Capture the authorized payment."})
|
||||
|
||||
// void_payment is the compensation for authorize_payment: void with the
|
||||
// provider and cancel the order if it is still in a cancellable state.
|
||||
reg.Action("void_payment", func(ctx context.Context, st *flow.State, _ json.RawMessage) error {
|
||||
reg.ActionWithMeta("void_payment", func(ctx context.Context, st *flow.State, _ json.RawMessage) error {
|
||||
if authRef, ok := st.Vars["authRef"].(string); ok && authRef != "" {
|
||||
if err := provider.Void(ctx, authRef); err != nil {
|
||||
return err
|
||||
@@ -134,9 +134,9 @@ func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvid
|
||||
// Best-effort cancel; ignore if no longer cancellable.
|
||||
_, _ = app.Apply(ctx, st.ID, &messages.CancelOrder{Reason: "compensation: payment voided", AtMs: nowMs()})
|
||||
return nil
|
||||
})
|
||||
}, flow.CapabilityMeta{Description: "Void the authorization and cancel the order as compensation."})
|
||||
|
||||
reg.Action("cancel_order", func(ctx context.Context, st *flow.State, params json.RawMessage) error {
|
||||
reg.ActionWithMeta("cancel_order", func(ctx context.Context, st *flow.State, params json.RawMessage) error {
|
||||
reason := "cancelled"
|
||||
if len(params) > 0 {
|
||||
var p struct {
|
||||
@@ -147,9 +147,12 @@ func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvid
|
||||
}
|
||||
}
|
||||
return applyOne(ctx, app, st.ID, &messages.CancelOrder{Reason: reason, AtMs: nowMs()})
|
||||
}, flow.CapabilityMeta{
|
||||
Description: "Cancel the order with an optional reason.",
|
||||
ExampleParams: json.RawMessage(`{"reason":"customer requested cancellation"}`),
|
||||
})
|
||||
|
||||
reg.Action("issue_refund", func(ctx context.Context, st *flow.State, params json.RawMessage) error {
|
||||
reg.ActionWithMeta("issue_refund", func(ctx context.Context, st *flow.State, params json.RawMessage) error {
|
||||
o, err := app.Get(ctx, st.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -180,6 +183,64 @@ func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvid
|
||||
Reference: ref.Reference,
|
||||
AtMs: nowMs(),
|
||||
})
|
||||
}, flow.CapabilityMeta{
|
||||
Description: "Refund the remaining or specified captured amount.",
|
||||
ExampleParams: json.RawMessage(`{"amount":2500}`),
|
||||
})
|
||||
|
||||
reg.ActionWithMeta("create_fulfillment", func(ctx context.Context, st *flow.State, params json.RawMessage) error {
|
||||
var p struct {
|
||||
ID string `json:"id"`
|
||||
Carrier string `json:"carrier,omitempty"`
|
||||
TrackingNumber string `json:"trackingNumber,omitempty"`
|
||||
TrackingURI string `json:"trackingUri,omitempty"`
|
||||
AtMs int64 `json:"atMs,omitempty"`
|
||||
Lines []struct {
|
||||
Reference string `json:"reference"`
|
||||
Quantity int32 `json:"quantity"`
|
||||
} `json:"lines"`
|
||||
}
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return fmt.Errorf("create_fulfillment: bad params: %w", err)
|
||||
}
|
||||
if len(p.Lines) == 0 {
|
||||
return fmt.Errorf("create_fulfillment: missing lines")
|
||||
}
|
||||
id := p.ID
|
||||
if id == "" {
|
||||
fid, err := NewOrderId()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id = "f_" + fid.String()
|
||||
}
|
||||
atMs := p.AtMs
|
||||
if atMs == 0 {
|
||||
atMs = nowMs()
|
||||
}
|
||||
msg := &messages.CreateFulfillment{
|
||||
Id: id,
|
||||
Carrier: p.Carrier,
|
||||
TrackingNumber: p.TrackingNumber,
|
||||
TrackingUri: p.TrackingURI,
|
||||
AtMs: atMs,
|
||||
}
|
||||
for _, line := range p.Lines {
|
||||
if line.Reference == "" {
|
||||
return fmt.Errorf("create_fulfillment: line missing reference")
|
||||
}
|
||||
if line.Quantity <= 0 {
|
||||
return fmt.Errorf("create_fulfillment: line %q has invalid quantity %d", line.Reference, line.Quantity)
|
||||
}
|
||||
msg.Lines = append(msg.Lines, &messages.FulfillmentLine{
|
||||
Reference: line.Reference,
|
||||
Quantity: line.Quantity,
|
||||
})
|
||||
}
|
||||
return applyOne(ctx, app, st.ID, msg)
|
||||
}, flow.CapabilityMeta{
|
||||
Description: "Create a shipment / fulfillment and advance the order fulfillment status.",
|
||||
ExampleParams: json.RawMessage(`{"carrier":"dropshipper","trackingNumber":"TRACK-123","trackingUri":"https://tracking.example/TRACK-123","lines":[{"reference":"l1","quantity":1}]}`),
|
||||
})
|
||||
|
||||
registerPredicates(reg, app)
|
||||
@@ -193,39 +254,39 @@ func RegisterFlowActions(reg *flow.Registry, app Applier, provider PaymentProvid
|
||||
func registerPredicates(reg *flow.Registry, app Applier) {
|
||||
get := func(ctx context.Context, st *flow.State) (*OrderGrain, error) { return app.Get(ctx, st.ID) }
|
||||
|
||||
reg.Predicate("has_customer_email", func(ctx context.Context, st *flow.State) (bool, error) {
|
||||
reg.PredicateWithMeta("has_customer_email", func(ctx context.Context, st *flow.State) (bool, error) {
|
||||
o, err := get(ctx, st)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return o.CustomerEmail != "", nil
|
||||
})
|
||||
reg.Predicate("has_items", func(ctx context.Context, st *flow.State) (bool, error) {
|
||||
}, flow.CapabilityMeta{Description: "Run only when the order has a customer email address."})
|
||||
reg.PredicateWithMeta("has_items", func(ctx context.Context, st *flow.State) (bool, error) {
|
||||
o, err := get(ctx, st)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return len(o.Lines) > 0, nil
|
||||
})
|
||||
reg.Predicate("is_captured", func(ctx context.Context, st *flow.State) (bool, error) {
|
||||
}, flow.CapabilityMeta{Description: "Run only when the order contains at least one line item."})
|
||||
reg.PredicateWithMeta("is_captured", func(ctx context.Context, st *flow.State) (bool, error) {
|
||||
o, err := get(ctx, st)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return o.Status == StatusCaptured, nil
|
||||
})
|
||||
reg.Predicate("not_captured", func(ctx context.Context, st *flow.State) (bool, error) {
|
||||
}, flow.CapabilityMeta{Description: "Run only after payment has been captured."})
|
||||
reg.PredicateWithMeta("not_captured", func(ctx context.Context, st *flow.State) (bool, error) {
|
||||
o, err := get(ctx, st)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return o.Status != StatusCaptured, nil
|
||||
})
|
||||
reg.Predicate("has_open_balance", func(ctx context.Context, st *flow.State) (bool, error) {
|
||||
}, flow.CapabilityMeta{Description: "Run only before payment is captured."})
|
||||
reg.PredicateWithMeta("has_open_balance", func(ctx context.Context, st *flow.State) (bool, error) {
|
||||
o, err := get(ctx, st)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return o.CapturedAmount-o.RefundedAmount > 0, nil
|
||||
})
|
||||
}, flow.CapabilityMeta{Description: "Run only when captured amount remains to refund."})
|
||||
}
|
||||
|
||||
@@ -3,19 +3,28 @@ package order
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/mail"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
||||
)
|
||||
|
||||
func fullRegistry(t *testing.T, pub Publisher) *flow.Registry {
|
||||
t.Helper()
|
||||
pool := newPool(t)
|
||||
reg := flow.NewRegistry()
|
||||
flow.RegisterBuiltinHooks(reg)
|
||||
RegisterFlowActions(reg, newPool(t), NewMockProvider())
|
||||
RegisterFlowActions(reg, pool, NewMockProvider())
|
||||
RegisterInventoryReservationActions(reg, pool, nil)
|
||||
RegisterEmitHook(reg, pub)
|
||||
RegisterEmailHook(reg, pool, nil, nil)
|
||||
RegisterFulfillmentWebhookHook(reg, pool, nil)
|
||||
return reg
|
||||
}
|
||||
|
||||
@@ -30,6 +39,92 @@ func TestCapabilitiesIncludesPredicatesAndEmit(t *testing.T) {
|
||||
if !slices.Contains(caps.Hooks, "amqp_emit") {
|
||||
t.Errorf("hooks missing amqp_emit (got %v)", caps.Hooks)
|
||||
}
|
||||
if !slices.Contains(caps.Hooks, "send_email") {
|
||||
t.Errorf("hooks missing send_email (got %v)", caps.Hooks)
|
||||
}
|
||||
if !slices.Contains(caps.Hooks, "fulfillment_webhook") {
|
||||
t.Errorf("hooks missing fulfillment_webhook (got %v)", caps.Hooks)
|
||||
}
|
||||
if !slices.Contains(caps.Actions, "create_fulfillment") {
|
||||
t.Errorf("actions missing create_fulfillment (got %v)", caps.Actions)
|
||||
}
|
||||
if !slices.Contains(caps.Actions, "reserve_inventory") {
|
||||
t.Errorf("actions missing reserve_inventory (got %v)", caps.Actions)
|
||||
}
|
||||
if !slices.Contains(caps.Actions, "release_inventory") {
|
||||
t.Errorf("actions missing release_inventory (got %v)", caps.Actions)
|
||||
}
|
||||
if caps.ActionMeta["create_fulfillment"].Description == "" {
|
||||
t.Fatalf("action meta missing for create_fulfillment: %#v", caps.ActionMeta["create_fulfillment"])
|
||||
}
|
||||
if len(caps.HookMeta["fulfillment_webhook"].ExampleParams) == 0 {
|
||||
t.Fatalf("hook meta missing example params for fulfillment_webhook: %#v", caps.HookMeta["fulfillment_webhook"])
|
||||
}
|
||||
if caps.PredicateMeta["has_customer_email"].Description == "" {
|
||||
t.Fatalf("predicate meta missing for has_customer_email: %#v", caps.PredicateMeta["has_customer_email"])
|
||||
}
|
||||
}
|
||||
|
||||
type fakeInventoryReservationService struct {
|
||||
reserved []InventoryReservationRequest
|
||||
released []InventoryReservationRequest
|
||||
}
|
||||
|
||||
func (f *fakeInventoryReservationService) Reserve(_ context.Context, req InventoryReservationRequest) error {
|
||||
f.reserved = append(f.reserved, req)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeInventoryReservationService) Release(_ context.Context, req InventoryReservationRequest) error {
|
||||
f.released = append(f.released, req)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestReserveInventoryActionBuildsReservationRequest(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
reg := flow.NewRegistry()
|
||||
RegisterFlowActions(reg, pool, NewMockProvider())
|
||||
fake := &fakeInventoryReservationService{}
|
||||
RegisterInventoryReservationActions(reg, pool, fake)
|
||||
eng := flow.NewEngine(reg, nil)
|
||||
|
||||
const id = 703
|
||||
st := flow.NewState(id, nil)
|
||||
po := placeMsg()
|
||||
st.Vars[PlaceOrderVar] = po
|
||||
def, err := EmbeddedFlow("place-and-pay")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := eng.Run(context.Background(), def, st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reserve := &flow.Definition{Name: "reserve", Steps: []flow.Step{{
|
||||
Name: "reserve",
|
||||
Action: "reserve_inventory",
|
||||
Params: json.RawMessage(`{"ttlSeconds":600,"lines":[{"reference":"l1","quantity":1,"locationId":"se"}]}`),
|
||||
Compensate: &flow.ActionRef{
|
||||
Action: "release_inventory",
|
||||
Params: json.RawMessage(`{"lines":[{"reference":"l1","quantity":1,"locationId":"se"}]}`),
|
||||
},
|
||||
}}}
|
||||
if _, err := eng.Run(context.Background(), reserve, flow.NewState(id, nil)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.reserved) != 1 {
|
||||
t.Fatalf("reserved = %d, want 1", len(fake.reserved))
|
||||
}
|
||||
got := fake.reserved[0]
|
||||
if got.HolderID != "order:"+OrderId(id).String() {
|
||||
t.Fatalf("holderId = %q", got.HolderID)
|
||||
}
|
||||
if got.TTL != 10*time.Minute {
|
||||
t.Fatalf("ttl = %s", got.TTL)
|
||||
}
|
||||
if len(got.Lines) != 1 || got.Lines[0].SKU != "ABC" || got.Lines[0].Quantity != 1 {
|
||||
t.Fatalf("lines = %+v", got.Lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCapturedPredicateGatesStep(t *testing.T) {
|
||||
@@ -94,3 +189,148 @@ func TestAmqpEmitHookPublishes(t *testing.T) {
|
||||
t.Fatalf("expected one message routed to k, got %v", fp.msgs)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeEmailSender struct {
|
||||
defaultFrom mail.Address
|
||||
msgs []EmailMessage
|
||||
}
|
||||
|
||||
func (f *fakeEmailSender) DefaultFrom() mail.Address { return f.defaultFrom }
|
||||
|
||||
func (f *fakeEmailSender) Send(_ context.Context, msg EmailMessage) error {
|
||||
f.msgs = append(f.msgs, msg)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSendEmailHookRendersFulfillmentTemplate(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
reg := flow.NewRegistry()
|
||||
RegisterFlowActions(reg, pool, NewMockProvider())
|
||||
|
||||
sender := &fakeEmailSender{defaultFrom: mail.Address{Name: "Ops", Address: "ops@example.com"}}
|
||||
templates := newEmailTemplateCatalog(map[string]EmailTemplate{
|
||||
"ship": {
|
||||
Subject: "Order {{ .Order.OrderReference }} shipped",
|
||||
Text: "Tracking {{ .Fulfillment.TrackingNumber }} for {{ .Order.CustomerEmail }}",
|
||||
},
|
||||
})
|
||||
RegisterEmailHook(reg, pool, sender, templates)
|
||||
eng := flow.NewEngine(reg, nil)
|
||||
|
||||
const id = 701
|
||||
st := flow.NewState(id, nil)
|
||||
po := placeMsg()
|
||||
po.CustomerEmail = "alice@example.com"
|
||||
po.CustomerName = "Alice"
|
||||
st.Vars[PlaceOrderVar] = po
|
||||
|
||||
def, err := EmbeddedFlow("place-and-pay")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := eng.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":"postnord","trackingNumber":"TRACK-123","trackingUri":"https://track.example/TRACK-123","lines":[{"reference":"l1","quantity":1}]}`),
|
||||
Hooks: flow.Hooks{After: []flow.HookRef{{Type: "send_email", Params: json.RawMessage(`{"template":"ship"}`)}}},
|
||||
}}}
|
||||
if _, err := eng.Run(context.Background(), ship, flow.NewState(id, nil)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(sender.msgs) != 1 {
|
||||
t.Fatalf("sent %d emails, want 1", len(sender.msgs))
|
||||
}
|
||||
got := sender.msgs[0]
|
||||
if got.From.Address != "ops@example.com" {
|
||||
t.Fatalf("from = %q, want ops@example.com", got.From.Address)
|
||||
}
|
||||
if len(got.To) != 1 || got.To[0].Address != "alice@example.com" {
|
||||
t.Fatalf("to = %+v, want alice@example.com", got.To)
|
||||
}
|
||||
if got.Subject != "Order ref-1 shipped" {
|
||||
t.Fatalf("subject = %q", got.Subject)
|
||||
}
|
||||
if !strings.Contains(got.TextBody, "TRACK-123") {
|
||||
t.Fatalf("text body = %q, want tracking number", got.TextBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFulfillmentWebhookPostsOrderPayload(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
reg := flow.NewRegistry()
|
||||
RegisterFlowActions(reg, pool, NewMockProvider())
|
||||
|
||||
var gotMethod, gotPath, gotHeader string
|
||||
var gotBody map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotMethod = r.Method
|
||||
gotPath = r.URL.Path
|
||||
gotHeader = r.Header.Get("X-Integration")
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
||||
t.Fatalf("decode webhook body: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
RegisterFulfillmentWebhookHook(reg, pool, srv.Client())
|
||||
eng := flow.NewEngine(reg, nil)
|
||||
|
||||
const id = 702
|
||||
st := flow.NewState(id, nil)
|
||||
po := placeMsg()
|
||||
po.CustomerEmail = "dropship@example.com"
|
||||
st.Vars[PlaceOrderVar] = po
|
||||
|
||||
def, err := EmbeddedFlow("place-and-pay")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := eng.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: "fulfillment_webhook",
|
||||
Params: json.RawMessage(fmt.Sprintf(`{
|
||||
"url": %q,
|
||||
"method": "POST",
|
||||
"headers": {
|
||||
"X-Integration": "dropship {{ .Order.OrderReference }}"
|
||||
}
|
||||
}`, srv.URL+`/hooks/{{ .OrderID }}`)),
|
||||
}}},
|
||||
}}}
|
||||
if _, err := eng.Run(context.Background(), ship, flow.NewState(id, nil)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if gotMethod != http.MethodPost {
|
||||
t.Fatalf("method = %q, want POST", gotMethod)
|
||||
}
|
||||
if gotPath != "/hooks/"+OrderId(id).String() {
|
||||
t.Fatalf("path = %q", gotPath)
|
||||
}
|
||||
if gotHeader != "dropship ref-1" {
|
||||
t.Fatalf("header = %q", gotHeader)
|
||||
}
|
||||
if gotBody["orderId"] != OrderId(id).String() {
|
||||
t.Fatalf("body orderId = %v", gotBody["orderId"])
|
||||
}
|
||||
fulfillment, ok := gotBody["fulfillment"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("body fulfillment missing: %#v", gotBody["fulfillment"])
|
||||
}
|
||||
if fulfillment["trackingNumber"] != "DS-123" {
|
||||
t.Fatalf("trackingNumber = %v", fulfillment["trackingNumber"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
package order
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
htmltmpl "html/template"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"mime/quotedprintable"
|
||||
"net"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
texttmpl "text/template"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
||||
)
|
||||
|
||||
// EmailMessage is the rendered mail payload a sender delivers.
|
||||
type EmailMessage struct {
|
||||
From mail.Address
|
||||
To []mail.Address
|
||||
Subject string
|
||||
TextBody string
|
||||
HTMLBody string
|
||||
}
|
||||
|
||||
// EmailSender delivers an already-rendered mail and exposes its default sender.
|
||||
type EmailSender interface {
|
||||
Send(ctx context.Context, msg EmailMessage) error
|
||||
DefaultFrom() mail.Address
|
||||
}
|
||||
|
||||
// SMTPEmailSenderConfig configures the SMTP mail transport.
|
||||
type SMTPEmailSenderConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
DefaultFrom mail.Address
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type smtpEmailSender struct {
|
||||
host string
|
||||
addr string
|
||||
auth smtp.Auth
|
||||
defaultFrom mail.Address
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// NewSMTPEmailSender returns an SMTP-backed sender. It supports plain SMTP with
|
||||
// opportunistic STARTTLS, which is the only configured transport for now.
|
||||
func NewSMTPEmailSender(cfg SMTPEmailSenderConfig) (EmailSender, error) {
|
||||
if strings.TrimSpace(cfg.Host) == "" {
|
||||
return nil, fmt.Errorf("smtp sender: missing host")
|
||||
}
|
||||
if cfg.Port <= 0 {
|
||||
return nil, fmt.Errorf("smtp sender: invalid port %d", cfg.Port)
|
||||
}
|
||||
if cfg.Timeout <= 0 {
|
||||
cfg.Timeout = 10 * time.Second
|
||||
}
|
||||
if strings.TrimSpace(cfg.DefaultFrom.Address) == "" {
|
||||
return nil, fmt.Errorf("smtp sender: missing default from address")
|
||||
}
|
||||
s := &smtpEmailSender{
|
||||
host: cfg.Host,
|
||||
addr: net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port)),
|
||||
defaultFrom: cfg.DefaultFrom,
|
||||
timeout: cfg.Timeout,
|
||||
}
|
||||
if cfg.Username != "" {
|
||||
s.auth = smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *smtpEmailSender) DefaultFrom() mail.Address { return s.defaultFrom }
|
||||
|
||||
func (s *smtpEmailSender) Send(ctx context.Context, msg EmailMessage) error {
|
||||
if strings.TrimSpace(msg.From.Address) == "" {
|
||||
return fmt.Errorf("smtp sender: missing from address")
|
||||
}
|
||||
if len(msg.To) == 0 {
|
||||
return fmt.Errorf("smtp sender: missing recipients")
|
||||
}
|
||||
if strings.TrimSpace(msg.Subject) == "" {
|
||||
return fmt.Errorf("smtp sender: missing subject")
|
||||
}
|
||||
body, err := buildSMTPMessage(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conn, err := (&net.Dialer{Timeout: s.timeout}).DialContext(ctx, "tcp", s.addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp sender: dial %s: %w", s.addr, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(s.timeout))
|
||||
|
||||
client, err := smtp.NewClient(conn, s.host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp sender: create client: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
if err := client.StartTLS(&tls.Config{ServerName: s.host, MinVersion: tls.VersionTLS12}); err != nil {
|
||||
return fmt.Errorf("smtp sender: starttls: %w", err)
|
||||
}
|
||||
}
|
||||
if s.auth != nil {
|
||||
if ok, _ := client.Extension("AUTH"); !ok {
|
||||
return fmt.Errorf("smtp sender: server does not support auth")
|
||||
}
|
||||
if err := client.Auth(s.auth); err != nil {
|
||||
return fmt.Errorf("smtp sender: auth: %w", err)
|
||||
}
|
||||
}
|
||||
if err := client.Mail(msg.From.Address); err != nil {
|
||||
return fmt.Errorf("smtp sender: mail from %s: %w", msg.From.Address, err)
|
||||
}
|
||||
for _, rcpt := range msg.To {
|
||||
if err := client.Rcpt(rcpt.Address); err != nil {
|
||||
return fmt.Errorf("smtp sender: rcpt to %s: %w", rcpt.Address, err)
|
||||
}
|
||||
}
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp sender: data: %w", err)
|
||||
}
|
||||
if _, err := w.Write(body); err != nil {
|
||||
_ = w.Close()
|
||||
return fmt.Errorf("smtp sender: write body: %w", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return fmt.Errorf("smtp sender: close body: %w", err)
|
||||
}
|
||||
if err := client.Quit(); err != nil {
|
||||
return fmt.Errorf("smtp sender: quit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildSMTPMessage(msg EmailMessage) ([]byte, error) {
|
||||
if strings.TrimSpace(msg.TextBody) == "" && strings.TrimSpace(msg.HTMLBody) == "" {
|
||||
return nil, fmt.Errorf("email: missing body")
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
writeHeader := func(name, value string) {
|
||||
buf.WriteString(name)
|
||||
buf.WriteString(": ")
|
||||
buf.WriteString(value)
|
||||
buf.WriteString("\r\n")
|
||||
}
|
||||
writeHeader("From", msg.From.String())
|
||||
toList := make([]string, 0, len(msg.To))
|
||||
for _, rcpt := range msg.To {
|
||||
toList = append(toList, rcpt.String())
|
||||
}
|
||||
writeHeader("To", strings.Join(toList, ", "))
|
||||
writeHeader("Subject", mime.QEncoding.Encode("utf-8", msg.Subject))
|
||||
writeHeader("Date", time.Now().Format(time.RFC1123Z))
|
||||
writeHeader("MIME-Version", "1.0")
|
||||
|
||||
switch {
|
||||
case msg.TextBody != "" && msg.HTMLBody != "":
|
||||
boundary := fmt.Sprintf("alt-%d", time.Now().UnixNano())
|
||||
writeHeader("Content-Type", fmt.Sprintf(`multipart/alternative; boundary="%s"`, boundary))
|
||||
buf.WriteString("\r\n")
|
||||
writeMIMEPart(&buf, boundary, "text/plain; charset=utf-8", msg.TextBody)
|
||||
writeMIMEPart(&buf, boundary, "text/html; charset=utf-8", msg.HTMLBody)
|
||||
buf.WriteString("--")
|
||||
buf.WriteString(boundary)
|
||||
buf.WriteString("--\r\n")
|
||||
case msg.HTMLBody != "":
|
||||
writeHeader("Content-Type", `text/html; charset=utf-8`)
|
||||
writeHeader("Content-Transfer-Encoding", "quoted-printable")
|
||||
buf.WriteString("\r\n")
|
||||
if err := writeQuotedPrintable(&buf, msg.HTMLBody); err != nil {
|
||||
return nil, fmt.Errorf("email: encode html body: %w", err)
|
||||
}
|
||||
default:
|
||||
writeHeader("Content-Type", `text/plain; charset=utf-8`)
|
||||
writeHeader("Content-Transfer-Encoding", "quoted-printable")
|
||||
buf.WriteString("\r\n")
|
||||
if err := writeQuotedPrintable(&buf, msg.TextBody); err != nil {
|
||||
return nil, fmt.Errorf("email: encode text body: %w", err)
|
||||
}
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func writeMIMEPart(buf *bytes.Buffer, boundary, contentType, body string) {
|
||||
buf.WriteString("--")
|
||||
buf.WriteString(boundary)
|
||||
buf.WriteString("\r\n")
|
||||
buf.WriteString("Content-Type: ")
|
||||
buf.WriteString(contentType)
|
||||
buf.WriteString("\r\n")
|
||||
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
|
||||
_ = writeQuotedPrintable(buf, body)
|
||||
buf.WriteString("\r\n")
|
||||
}
|
||||
|
||||
func writeQuotedPrintable(buf *bytes.Buffer, body string) error {
|
||||
w := quotedprintable.NewWriter(buf)
|
||||
if _, err := w.Write([]byte(strings.ReplaceAll(body, "\n", "\r\n"))); err != nil {
|
||||
return err
|
||||
}
|
||||
return w.Close()
|
||||
}
|
||||
|
||||
// EmailTemplate is a named mail template, typically selected by a flow hook.
|
||||
type EmailTemplate struct {
|
||||
Subject string `json:"subject"`
|
||||
Text string `json:"text,omitempty"`
|
||||
HTML string `json:"html,omitempty"`
|
||||
}
|
||||
|
||||
func (t EmailTemplate) validate(name string) error {
|
||||
if strings.TrimSpace(t.Subject) == "" {
|
||||
return fmt.Errorf("email template %q: missing subject", name)
|
||||
}
|
||||
if strings.TrimSpace(t.Text) == "" && strings.TrimSpace(t.HTML) == "" {
|
||||
return fmt.Errorf("email template %q: missing text/html body", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EmailTemplateStore resolves a template by name.
|
||||
type EmailTemplateStore interface {
|
||||
Get(name string) (EmailTemplate, error)
|
||||
}
|
||||
|
||||
type emailTemplateCatalog struct {
|
||||
templates map[string]EmailTemplate
|
||||
}
|
||||
|
||||
func newEmailTemplateCatalog(seed map[string]EmailTemplate) *emailTemplateCatalog {
|
||||
out := &emailTemplateCatalog{templates: make(map[string]EmailTemplate, len(seed))}
|
||||
for name, tmpl := range seed {
|
||||
out.templates[name] = tmpl
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *emailTemplateCatalog) Get(name string) (EmailTemplate, error) {
|
||||
tmpl, ok := c.templates[name]
|
||||
if !ok {
|
||||
return EmailTemplate{}, fmt.Errorf("unknown template %q", name)
|
||||
}
|
||||
return tmpl, nil
|
||||
}
|
||||
|
||||
//go:embed email_templates/*.json
|
||||
var embeddedEmailTemplateFS embed.FS
|
||||
|
||||
// LoadEmailTemplates returns the embedded templates overlaid by any JSON files in
|
||||
// dir (same precedence model as flow definitions: on-disk wins).
|
||||
func LoadEmailTemplates(dir string) (EmailTemplateStore, error) {
|
||||
templates := map[string]EmailTemplate{}
|
||||
|
||||
matches, err := fs.Glob(embeddedEmailTemplateFS, "email_templates/*.json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list embedded email templates: %w", err)
|
||||
}
|
||||
for _, match := range matches {
|
||||
data, err := embeddedEmailTemplateFS.ReadFile(match)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read embedded email template %s: %w", match, err)
|
||||
}
|
||||
name := strings.TrimSuffix(filepath.Base(match), ".json")
|
||||
tmpl, err := parseEmailTemplate(name, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
templates[name] = tmpl
|
||||
}
|
||||
|
||||
if dir != "" {
|
||||
if _, err := os.Stat(dir); err == nil {
|
||||
files, err := filepath.Glob(filepath.Join(dir, "*.json"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list email templates in %s: %w", dir, err)
|
||||
}
|
||||
for _, file := range files {
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read email template %s: %w", file, err)
|
||||
}
|
||||
name := strings.TrimSuffix(filepath.Base(file), ".json")
|
||||
tmpl, err := parseEmailTemplate(name, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
templates[name] = tmpl
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("stat email template dir %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
return newEmailTemplateCatalog(templates), nil
|
||||
}
|
||||
|
||||
func parseEmailTemplate(name string, data []byte) (EmailTemplate, error) {
|
||||
var tmpl EmailTemplate
|
||||
if err := json.Unmarshal(data, &tmpl); err != nil {
|
||||
return EmailTemplate{}, fmt.Errorf("parse email template %q: %w", name, err)
|
||||
}
|
||||
if err := tmpl.validate(name); err != nil {
|
||||
return EmailTemplate{}, err
|
||||
}
|
||||
return tmpl, nil
|
||||
}
|
||||
|
||||
type emailHookParams struct {
|
||||
Template string `json:"template"`
|
||||
To string `json:"to,omitempty"`
|
||||
From string `json:"from,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
}
|
||||
|
||||
type flowTemplateData struct {
|
||||
Order *OrderGrain
|
||||
OrderID string
|
||||
Step string
|
||||
Phase flow.Phase
|
||||
Error string
|
||||
Vars map[string]any
|
||||
Fulfillment *Fulfillment
|
||||
ShippingAddress any
|
||||
BillingAddress any
|
||||
}
|
||||
|
||||
// RegisterEmailHook registers the "send_email" flow hook. It reads the current
|
||||
// order for template data, renders the named template, and delivers it through
|
||||
// the configured sender. Hook params:
|
||||
//
|
||||
// {
|
||||
// "template": "fulfillment-shipped",
|
||||
// "to": "{{ .Order.CustomerEmail }}",
|
||||
// "from": "Warehouse <warehouse@example.com>"
|
||||
// }
|
||||
func RegisterEmailHook(reg *flow.Registry, app Applier, sender EmailSender, templates EmailTemplateStore) {
|
||||
reg.HookWithMeta("send_email", func(ctx context.Context, st *flow.State, info flow.HookInfo, params json.RawMessage) error {
|
||||
if sender == nil {
|
||||
return fmt.Errorf("send_email: no sender configured")
|
||||
}
|
||||
if templates == nil {
|
||||
return fmt.Errorf("send_email: no template store configured")
|
||||
}
|
||||
var p emailHookParams
|
||||
if len(params) > 0 {
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return fmt.Errorf("send_email: bad params: %w", err)
|
||||
}
|
||||
}
|
||||
if p.Template == "" {
|
||||
return fmt.Errorf("send_email: missing template")
|
||||
}
|
||||
o, err := app.Get(ctx, st.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpl, err := templates.Get(p.Template)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send_email: %w", err)
|
||||
}
|
||||
data := buildFlowTemplateData(o, st, info)
|
||||
|
||||
subjectSource := tmpl.Subject
|
||||
if p.Subject != "" {
|
||||
subjectSource = p.Subject
|
||||
}
|
||||
subject, err := renderTextTemplate("email-subject", subjectSource, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send_email: render subject: %w", err)
|
||||
}
|
||||
textBody, err := renderOptionalTextTemplate("email-text", tmpl.Text, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send_email: render text body: %w", err)
|
||||
}
|
||||
htmlBody, err := renderOptionalHTMLTemplate("email-html", tmpl.HTML, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send_email: render html body: %w", err)
|
||||
}
|
||||
|
||||
toSource := p.To
|
||||
if strings.TrimSpace(toSource) == "" {
|
||||
toSource = o.CustomerEmail
|
||||
}
|
||||
toRendered, err := renderTextTemplate("email-to", toSource, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send_email: render recipient: %w", err)
|
||||
}
|
||||
recipients, err := mail.ParseAddressList(toRendered)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send_email: parse recipient %q: %w", toRendered, err)
|
||||
}
|
||||
to := make([]mail.Address, 0, len(recipients))
|
||||
for _, rcpt := range recipients {
|
||||
to = append(to, *rcpt)
|
||||
}
|
||||
|
||||
from := sender.DefaultFrom()
|
||||
if strings.TrimSpace(p.From) != "" {
|
||||
fromRendered, err := renderTextTemplate("email-from", p.From, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send_email: render sender: %w", err)
|
||||
}
|
||||
parsed, err := mail.ParseAddress(strings.TrimSpace(fromRendered))
|
||||
if err != nil {
|
||||
return fmt.Errorf("send_email: parse sender %q: %w", fromRendered, err)
|
||||
}
|
||||
from = *parsed
|
||||
}
|
||||
|
||||
return sender.Send(ctx, EmailMessage{
|
||||
From: from,
|
||||
To: to,
|
||||
Subject: subject,
|
||||
TextBody: textBody,
|
||||
HTMLBody: htmlBody,
|
||||
})
|
||||
}, flow.CapabilityMeta{
|
||||
Description: "Render a named email template and send it through the configured sender.",
|
||||
ExampleParams: json.RawMessage(`{"template":"fulfillment-shipped","to":"{{ .Order.CustomerEmail }}","from":"Store <no-reply@example.com>"}`),
|
||||
})
|
||||
}
|
||||
|
||||
func buildFlowTemplateData(o *OrderGrain, st *flow.State, info flow.HookInfo) flowTemplateData {
|
||||
errStr := ""
|
||||
if info.Err != nil {
|
||||
errStr = info.Err.Error()
|
||||
}
|
||||
var lastFulfillment *Fulfillment
|
||||
if n := len(o.Fulfillments); n > 0 {
|
||||
lastFulfillment = &o.Fulfillments[n-1]
|
||||
}
|
||||
return flowTemplateData{
|
||||
Order: o,
|
||||
OrderID: OrderId(st.ID).String(),
|
||||
Step: info.Step,
|
||||
Phase: info.Phase,
|
||||
Error: errStr,
|
||||
Vars: st.Vars,
|
||||
Fulfillment: lastFulfillment,
|
||||
ShippingAddress: decodeEmailTemplateJSON(o.ShippingAddress),
|
||||
BillingAddress: decodeEmailTemplateJSON(o.BillingAddress),
|
||||
}
|
||||
}
|
||||
|
||||
func decodeEmailTemplateJSON(raw json.RawMessage) any {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
var v any
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
return string(raw)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func renderTextTemplate(name, src string, data any) (string, error) {
|
||||
tmpl, err := texttmpl.New(name).Option("missingkey=error").Parse(src)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(buf.String()), nil
|
||||
}
|
||||
|
||||
func renderOptionalTextTemplate(name, src string, data any) (string, error) {
|
||||
if strings.TrimSpace(src) == "" {
|
||||
return "", nil
|
||||
}
|
||||
return renderTextTemplate(name, src, data)
|
||||
}
|
||||
|
||||
func renderOptionalHTMLTemplate(name, src string, data any) (string, error) {
|
||||
if strings.TrimSpace(src) == "" {
|
||||
return "", nil
|
||||
}
|
||||
tmpl, err := htmltmpl.New(name).Option("missingkey=error").Parse(src)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(buf.String()), nil
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"subject": "Your order {{ .Order.OrderReference }} has shipped",
|
||||
"text": "Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},\n\nYour order {{ .Order.OrderReference }} has shipped.\n{{ with .Fulfillment }}Carrier: {{ .Carrier }}\nTracking number: {{ .TrackingNumber }}\n{{ if .TrackingURI }}Track your shipment: {{ .TrackingURI }}\n{{ end }}{{ end }}\nOrder id: {{ .OrderID }}\n",
|
||||
"html": "<p>Hi {{ if .Order.CustomerName }}{{ .Order.CustomerName }}{{ else }}there{{ end }},</p><p>Your order <strong>{{ .Order.OrderReference }}</strong> has shipped.</p>{{ with .Fulfillment }}<ul><li><strong>Carrier:</strong> {{ .Carrier }}</li><li><strong>Tracking number:</strong> {{ .TrackingNumber }}</li>{{ if .TrackingURI }}<li><a href=\"{{ .TrackingURI }}\">Track your shipment</a></li>{{ end }}</ul>{{ end }}<p>Order id: <code>{{ .OrderID }}</code></p>"
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
// retries delivery on its own. With a nil publisher (dev without AMQP) it is a
|
||||
// no-op. Attach it to the "after" hooks of the final paid step, e.g. capture.
|
||||
func RegisterOrderCreatedEmit(reg *flow.Registry, app Applier, pub Publisher, exchange, source string) {
|
||||
reg.Hook("emit_order_created", func(ctx context.Context, st *flow.State, _ flow.HookInfo, _ json.RawMessage) error {
|
||||
reg.HookWithMeta("emit_order_created", func(ctx context.Context, st *flow.State, _ flow.HookInfo, _ json.RawMessage) error {
|
||||
if pub == nil {
|
||||
return nil // no broker configured (dev)
|
||||
}
|
||||
@@ -51,19 +51,21 @@ func RegisterOrderCreatedEmit(reg *flow.Registry, app Applier, pub Publisher, ex
|
||||
return err
|
||||
}
|
||||
return pub.Publish(exchange, string(event.OrderCreated), body)
|
||||
}, flow.CapabilityMeta{
|
||||
Description: "Emit the order.created domain event after a paid order is finalized.",
|
||||
})
|
||||
}
|
||||
|
||||
// buildOrderCreated projects the grain onto the exported order.created contract.
|
||||
// Lines carry only SKU + quantity (what reactors need); inventory commits at the
|
||||
// order's Country location.
|
||||
// Lines carry SKU, quantity, the inventory commit location, and the drop-ship
|
||||
// flag so the inventory reactor can skip the stock decrement for supplier-fulfilled items.
|
||||
func buildOrderCreated(o *OrderGrain) contract.Created {
|
||||
lines := make([]contract.Line, 0, len(o.Lines))
|
||||
for _, l := range o.Lines {
|
||||
if l.Sku == "" || l.Quantity <= 0 {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, contract.Line{SKU: l.Sku, Quantity: l.Quantity, Location: l.Location})
|
||||
lines = append(lines, contract.Line{SKU: l.Sku, Quantity: l.Quantity, Location: l.Location, DropShip: l.DropShip})
|
||||
}
|
||||
return contract.Created{
|
||||
OrderID: OrderId(o.Id).String(),
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package order
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
||||
)
|
||||
|
||||
type fulfillmentWebhookParams struct {
|
||||
URL string `json:"url"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
ContentType string `json:"contentType,omitempty"`
|
||||
}
|
||||
|
||||
// RegisterFulfillmentWebhookHook registers the "fulfillment_webhook" flow hook.
|
||||
// It posts the current order + latest fulfillment to an external integration,
|
||||
// with templated URL/headers so flows can target dropship or ERP adapters
|
||||
// without custom Go code. Params:
|
||||
//
|
||||
// {
|
||||
// "url": "https://example.test/hooks/{{ .OrderID }}",
|
||||
// "method": "POST",
|
||||
// "headers": {
|
||||
// "X-Integration": "dropship",
|
||||
// "Authorization": "Bearer {{ index .Vars \"token\" }}"
|
||||
// }
|
||||
// }
|
||||
func RegisterFulfillmentWebhookHook(reg *flow.Registry, app Applier, client *http.Client) {
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
reg.HookWithMeta("fulfillment_webhook", func(ctx context.Context, st *flow.State, info flow.HookInfo, params json.RawMessage) error {
|
||||
var p fulfillmentWebhookParams
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return fmt.Errorf("fulfillment_webhook: bad params: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(p.URL) == "" {
|
||||
return fmt.Errorf("fulfillment_webhook: missing url")
|
||||
}
|
||||
method := strings.ToUpper(strings.TrimSpace(p.Method))
|
||||
if method == "" {
|
||||
method = http.MethodPost
|
||||
}
|
||||
if p.ContentType == "" {
|
||||
p.ContentType = "application/json"
|
||||
}
|
||||
|
||||
o, err := app.Get(ctx, st.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data := buildFlowTemplateData(o, st, info)
|
||||
if data.Fulfillment == nil {
|
||||
return fmt.Errorf("fulfillment_webhook: order %s has no fulfillment yet", data.OrderID)
|
||||
}
|
||||
|
||||
url, err := renderTextTemplate("fulfillment-webhook-url", p.URL, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fulfillment_webhook: render url: %w", err)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"orderId": data.OrderID,
|
||||
"step": data.Step,
|
||||
"phase": data.Phase,
|
||||
"error": data.Error,
|
||||
"order": data.Order,
|
||||
"fulfillment": data.Fulfillment,
|
||||
"billingAddress": data.BillingAddress,
|
||||
"shippingAddress": data.ShippingAddress,
|
||||
"vars": data.Vars,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("fulfillment_webhook: encode body: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("fulfillment_webhook: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", p.ContentType)
|
||||
for key, raw := range p.Headers {
|
||||
value, err := renderTextTemplate("fulfillment-webhook-header-"+key, raw, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fulfillment_webhook: render header %s: %w", key, err)
|
||||
}
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fulfillment_webhook: post %s: %w", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("fulfillment_webhook: %s returned %d", url, resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}, flow.CapabilityMeta{
|
||||
Description: "POST the order and latest fulfillment to an external dropship or ERP endpoint.",
|
||||
ExampleParams: json.RawMessage(`{"url":"https://dropship.example/hooks/{{ .OrderID }}","method":"POST","headers":{"X-Integration":"dropship {{ .Order.OrderReference }}","Authorization":"Bearer {{ index .Vars \"token\" }}"}}`),
|
||||
})
|
||||
}
|
||||
+4
-1
@@ -26,7 +26,7 @@ type Publisher interface {
|
||||
// still registered (so the editor lists it) but errors at run time — and since
|
||||
// hook errors never abort a flow, that failure is logged, not fatal.
|
||||
func RegisterEmitHook(reg *flow.Registry, pub Publisher) {
|
||||
reg.Hook("amqp_emit", func(_ context.Context, st *flow.State, info flow.HookInfo, params json.RawMessage) error {
|
||||
reg.HookWithMeta("amqp_emit", func(_ context.Context, st *flow.State, info flow.HookInfo, params json.RawMessage) error {
|
||||
if pub == nil {
|
||||
return fmt.Errorf("amqp_emit: no publisher configured")
|
||||
}
|
||||
@@ -54,5 +54,8 @@ func RegisterEmitHook(reg *flow.Registry, pub Publisher) {
|
||||
"vars": st.Vars,
|
||||
})
|
||||
return pub.Publish(p.Exchange, p.RoutingKey, body)
|
||||
}, flow.CapabilityMeta{
|
||||
Description: "Publish the flow event payload to RabbitMQ.",
|
||||
ExampleParams: json.RawMessage(`{"exchange":"order","routingKey":"order-events"}`),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package order
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
||||
)
|
||||
|
||||
type InventoryReservationLine struct {
|
||||
SKU string `json:"sku"`
|
||||
LocationID string `json:"locationId"`
|
||||
Quantity uint32 `json:"quantity"`
|
||||
}
|
||||
|
||||
type InventoryReservationRequest struct {
|
||||
HolderID string `json:"holderId"`
|
||||
TTL time.Duration `json:"-"`
|
||||
Lines []InventoryReservationLine `json:"lines"`
|
||||
}
|
||||
|
||||
type InventoryReservationService interface {
|
||||
Reserve(ctx context.Context, req InventoryReservationRequest) error
|
||||
Release(ctx context.Context, req InventoryReservationRequest) error
|
||||
}
|
||||
|
||||
type HTTPInventoryReservationService struct {
|
||||
baseURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewHTTPInventoryReservationService(baseURL string, client *http.Client) (*HTTPInventoryReservationService, error) {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if baseURL == "" {
|
||||
return nil, fmt.Errorf("inventory reservation service: missing base url")
|
||||
}
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
return &HTTPInventoryReservationService{baseURL: baseURL, client: client}, nil
|
||||
}
|
||||
|
||||
func (s *HTTPInventoryReservationService) Reserve(ctx context.Context, req InventoryReservationRequest) error {
|
||||
return s.post(ctx, "/reservations", req)
|
||||
}
|
||||
|
||||
func (s *HTTPInventoryReservationService) Release(ctx context.Context, req InventoryReservationRequest) error {
|
||||
return s.post(ctx, "/reservations/release", req)
|
||||
}
|
||||
|
||||
func (s *HTTPInventoryReservationService) post(ctx context.Context, path string, req InventoryReservationRequest) error {
|
||||
body := map[string]any{
|
||||
"holderId": req.HolderID,
|
||||
"lines": req.Lines,
|
||||
}
|
||||
if req.TTL > 0 {
|
||||
body["ttlSeconds"] = int64(req.TTL / time.Second)
|
||||
}
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, s.baseURL+path, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
resp, err := s.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("inventory reservation service: %s returned %d", path, resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type reserveInventoryParams struct {
|
||||
HolderID string `json:"holderId,omitempty"`
|
||||
TTLSeconds int64 `json:"ttlSeconds,omitempty"`
|
||||
Lines []struct {
|
||||
Reference string `json:"reference"`
|
||||
Quantity int32 `json:"quantity"`
|
||||
LocationID string `json:"locationId,omitempty"`
|
||||
} `json:"lines"`
|
||||
}
|
||||
|
||||
func RegisterInventoryReservationActions(reg *flow.Registry, app Applier, svc InventoryReservationService) {
|
||||
reg.ActionWithMeta("reserve_inventory", func(ctx context.Context, st *flow.State, params json.RawMessage) error {
|
||||
if svc == nil {
|
||||
return fmt.Errorf("reserve_inventory: no reservation service configured")
|
||||
}
|
||||
req, err := buildInventoryReservationRequest(ctx, app, st, params, "reserve_inventory")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return svc.Reserve(ctx, req)
|
||||
}, flow.CapabilityMeta{
|
||||
Description: "Reserve inventory for specific order lines through the inventory service.",
|
||||
ExampleParams: json.RawMessage(`{"ttlSeconds":900,"lines":[{"reference":"l1","quantity":1,"locationId":"se"}]}`),
|
||||
})
|
||||
|
||||
reg.ActionWithMeta("release_inventory", func(ctx context.Context, st *flow.State, params json.RawMessage) error {
|
||||
if svc == nil {
|
||||
return fmt.Errorf("release_inventory: no reservation service configured")
|
||||
}
|
||||
req, err := buildInventoryReservationRequest(ctx, app, st, params, "release_inventory")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return svc.Release(ctx, req)
|
||||
}, flow.CapabilityMeta{
|
||||
Description: "Release a prior inventory reservation for specific order lines.",
|
||||
ExampleParams: json.RawMessage(`{"lines":[{"reference":"l1","quantity":1,"locationId":"se"}]}`),
|
||||
})
|
||||
}
|
||||
|
||||
func buildInventoryReservationRequest(ctx context.Context, app Applier, st *flow.State, params json.RawMessage, action string) (InventoryReservationRequest, error) {
|
||||
var p reserveInventoryParams
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return InventoryReservationRequest{}, fmt.Errorf("%s: bad params: %w", action, err)
|
||||
}
|
||||
if len(p.Lines) == 0 {
|
||||
return InventoryReservationRequest{}, fmt.Errorf("%s: missing lines", action)
|
||||
}
|
||||
o, err := app.Get(ctx, st.ID)
|
||||
if err != nil {
|
||||
return InventoryReservationRequest{}, err
|
||||
}
|
||||
holderID := p.HolderID
|
||||
if holderID == "" {
|
||||
holderID = "order:" + OrderId(st.ID).String()
|
||||
}
|
||||
req := InventoryReservationRequest{HolderID: holderID}
|
||||
if p.TTLSeconds > 0 {
|
||||
req.TTL = time.Duration(p.TTLSeconds) * time.Second
|
||||
}
|
||||
for _, lineReq := range p.Lines {
|
||||
if lineReq.Reference == "" {
|
||||
return InventoryReservationRequest{}, fmt.Errorf("%s: line missing reference", action)
|
||||
}
|
||||
if lineReq.Quantity <= 0 {
|
||||
return InventoryReservationRequest{}, fmt.Errorf("%s: line %q has invalid quantity %d", action, lineReq.Reference, lineReq.Quantity)
|
||||
}
|
||||
line := o.findLine(lineReq.Reference)
|
||||
if line == nil {
|
||||
return InventoryReservationRequest{}, fmt.Errorf("%s: unknown line %q", action, lineReq.Reference)
|
||||
}
|
||||
if line.Sku == "" {
|
||||
return InventoryReservationRequest{}, fmt.Errorf("%s: line %q has no sku", action, lineReq.Reference)
|
||||
}
|
||||
remaining := line.Quantity - line.Fulfilled
|
||||
if int(lineReq.Quantity) > remaining {
|
||||
return InventoryReservationRequest{}, fmt.Errorf("%s: line %q quantity %d exceeds remaining fulfillable %d", action, lineReq.Reference, lineReq.Quantity, remaining)
|
||||
}
|
||||
locationID := strings.TrimSpace(lineReq.LocationID)
|
||||
if locationID == "" {
|
||||
locationID = strings.TrimSpace(line.Location)
|
||||
}
|
||||
if locationID == "" {
|
||||
locationID = strings.TrimSpace(o.Country)
|
||||
}
|
||||
if locationID == "" {
|
||||
return InventoryReservationRequest{}, fmt.Errorf("%s: line %q has no location", action, lineReq.Reference)
|
||||
}
|
||||
req.Lines = append(req.Lines, InventoryReservationLine{
|
||||
SKU: line.Sku,
|
||||
LocationID: locationID,
|
||||
Quantity: uint32(lineReq.Quantity),
|
||||
})
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
@@ -60,6 +60,7 @@ func HandlePlaceOrder(o *OrderGrain, m *messages.PlaceOrder) error {
|
||||
TotalAmount: money.Cents(l.GetTotalAmount()),
|
||||
TotalTax: money.Cents(l.GetTotalTax()),
|
||||
Location: l.GetLocation(),
|
||||
DropShip: l.GetDropShip(),
|
||||
})
|
||||
}
|
||||
o.Status = StatusPending
|
||||
|
||||
@@ -66,6 +66,9 @@ type Line struct {
|
||||
// Location is the inventory location / store id to commit against (empty =
|
||||
// order country). Carried through to the order.created event for commit.
|
||||
Location string `json:"location,omitempty"`
|
||||
// DropShip marks items fulfilled by the supplier; the inventory reactor
|
||||
// must skip the stock decrement. Set from the cart item at order creation.
|
||||
DropShip bool `json:"drop_ship,omitempty"`
|
||||
// Fulfilled tracks how many units of this line have shipped.
|
||||
Fulfilled int `json:"fulfilled"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user