232 lines
7.2 KiB
Go
232 lines
7.2 KiB
Go
package order
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
|
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
|
messages "git.k6n.net/mats/go-cart-actor/proto/order"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
//go:embed flows/*.json
|
|
var flowFS embed.FS
|
|
|
|
// EmbeddedFlow returns a built-in flow definition by name (e.g. "place-and-pay").
|
|
// Deployments can ship their own JSON instead; this is the default.
|
|
func EmbeddedFlow(name string) (*flow.Definition, error) {
|
|
data, err := flowFS.ReadFile("flows/" + name + ".json")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("order: no embedded flow %q: %w", name, err)
|
|
}
|
|
return flow.Parse(data)
|
|
}
|
|
|
|
// Applier is the slice of the grain pool the order flow actions need. The
|
|
// SimpleGrainPool[OrderGrain] satisfies it directly.
|
|
type Applier interface {
|
|
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[OrderGrain], error)
|
|
Get(ctx context.Context, id uint64) (*OrderGrain, error)
|
|
}
|
|
|
|
func nowMs() int64 { return time.Now().UnixMilli() }
|
|
|
|
// applyOne applies a single mutation and surfaces the handler error. The grain
|
|
// pool only returns a top-level error for *unregistered* mutations; a handler
|
|
// error (e.g. an illegal state transition) is carried per-mutation in the
|
|
// result, so a flow action must inspect it or a rejected event looks like a
|
|
// success and the flow proceeds wrongly.
|
|
func applyOne(ctx context.Context, app Applier, id uint64, msg proto.Message) error {
|
|
res, err := app.Apply(ctx, id, msg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if res != nil {
|
|
for _, m := range res.Mutations {
|
|
if m.Error != nil {
|
|
return m.Error
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PlaceOrderVar is the key under which the caller stores the *messages.PlaceOrder
|
|
// (built from the cart/checkout) in the flow State before running a flow. The
|
|
// flow config controls sequence + hooks; the order data flows in via State.
|
|
const PlaceOrderVar = "placeOrder"
|
|
|
|
// RegisterFlowActions registers the order lifecycle actions on reg, driving the
|
|
// 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 {
|
|
po, ok := st.Vars[PlaceOrderVar].(*messages.PlaceOrder)
|
|
if !ok || po == nil {
|
|
return fmt.Errorf("place_order: flow var %q is not a *PlaceOrder", PlaceOrderVar)
|
|
}
|
|
if po.PlacedAtMs == 0 {
|
|
po.PlacedAtMs = nowMs()
|
|
}
|
|
if err := applyOne(ctx, app, st.ID, po); err != nil {
|
|
return err
|
|
}
|
|
st.Vars["currency"] = po.GetCurrency()
|
|
st.Vars["orderReference"] = po.GetOrderReference()
|
|
return nil
|
|
})
|
|
|
|
reg.Action("authorize_payment", func(ctx context.Context, st *flow.State, _ json.RawMessage) error {
|
|
o, err := app.Get(ctx, st.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
auth, err := provider.Authorize(ctx, o.OrderReference, o.TotalAmount.Int64(), o.Currency)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := applyOne(ctx, app, st.ID, &messages.AuthorizePayment{
|
|
Provider: auth.Provider,
|
|
Amount: auth.Amount,
|
|
Reference: auth.Reference,
|
|
AtMs: nowMs(),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
st.Vars["authRef"] = auth.Reference
|
|
st.Vars["authProvider"] = auth.Provider
|
|
st.Vars["authAmount"] = auth.Amount
|
|
return nil
|
|
})
|
|
|
|
reg.Action("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 {
|
|
if o, err := app.Get(ctx, st.ID); err == nil {
|
|
amount = o.TotalAmount.Int64()
|
|
}
|
|
}
|
|
capture, err := provider.Capture(ctx, authRef, amount)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return applyOne(ctx, app, st.ID, &messages.CapturePayment{
|
|
Provider: capture.Provider,
|
|
Amount: capture.Amount,
|
|
Reference: capture.Reference,
|
|
AtMs: nowMs(),
|
|
})
|
|
})
|
|
|
|
// 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 {
|
|
if authRef, ok := st.Vars["authRef"].(string); ok && authRef != "" {
|
|
if err := provider.Void(ctx, authRef); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// Best-effort cancel; ignore if no longer cancellable.
|
|
_, _ = app.Apply(ctx, st.ID, &messages.CancelOrder{Reason: "compensation: payment voided", AtMs: nowMs()})
|
|
return nil
|
|
})
|
|
|
|
reg.Action("cancel_order", func(ctx context.Context, st *flow.State, params json.RawMessage) error {
|
|
reason := "cancelled"
|
|
if len(params) > 0 {
|
|
var p struct {
|
|
Reason string `json:"reason"`
|
|
}
|
|
if json.Unmarshal(params, &p) == nil && p.Reason != "" {
|
|
reason = p.Reason
|
|
}
|
|
}
|
|
return applyOne(ctx, app, st.ID, &messages.CancelOrder{Reason: reason, AtMs: nowMs()})
|
|
})
|
|
|
|
reg.Action("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
|
|
}
|
|
amount := (o.CapturedAmount - o.RefundedAmount).Int64() // default: full remaining
|
|
if len(params) > 0 {
|
|
var p struct {
|
|
Amount int64 `json:"amount"`
|
|
}
|
|
if json.Unmarshal(params, &p) == nil && p.Amount > 0 {
|
|
amount = p.Amount
|
|
}
|
|
}
|
|
captureRef := ""
|
|
for _, p := range o.Payments {
|
|
if p.Captured > 0 && p.CaptureRef != "" {
|
|
captureRef = p.CaptureRef
|
|
break
|
|
}
|
|
}
|
|
ref, err := provider.Refund(ctx, captureRef, amount)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return applyOne(ctx, app, st.ID, &messages.IssueRefund{
|
|
Provider: ref.Provider,
|
|
Amount: ref.Amount,
|
|
Reference: ref.Reference,
|
|
AtMs: nowMs(),
|
|
})
|
|
})
|
|
|
|
registerPredicates(reg, app)
|
|
}
|
|
|
|
// registerPredicates adds the order gating predicates a step can reference via
|
|
// its "when". They read the current order grain, so they reflect the live state
|
|
// at the moment the step is reached (e.g. only fire a receipt hook once the
|
|
// order is captured). Predicates take no params (the engine's When is a bare
|
|
// name), so each is a fixed, composable boolean.
|
|
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) {
|
|
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) {
|
|
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) {
|
|
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) {
|
|
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) {
|
|
o, err := get(ctx, st)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return o.CapturedAmount-o.RefundedAmount > 0, nil
|
|
})
|
|
}
|