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 }) }