cart and checkout
This commit is contained in:
+22
-105
@@ -3,13 +3,9 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"hash/fnv"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
messages "git.k6n.net/mats/go-cart-actor/proto/order"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
@@ -73,135 +69,56 @@ func (p *rabbitPublisher) Publish(exchange, routingKey string, body []byte) erro
|
||||
return err
|
||||
}
|
||||
|
||||
// This ingester lets the order service supersede the legacy go-order-manager:
|
||||
// it consumes the same "order-queue" the Klarna/Adyen checkout publishes to and
|
||||
// records each completed order as an event-sourced grain. Those orders are
|
||||
// already paid by the external processor, so we record payment with provider
|
||||
// "legacy" (place -> authorize -> capture) rather than charging again.
|
||||
|
||||
// legacyOrder is the subset of go-order-manager's Order JSON we need.
|
||||
type legacyOrder struct {
|
||||
ID string `json:"order_id"`
|
||||
PurchaseCountry string `json:"purchase_country"`
|
||||
PurchaseCurrency string `json:"purchase_currency"`
|
||||
Locale string `json:"locale"`
|
||||
OrderAmount int64 `json:"order_amount"`
|
||||
OrderTaxAmount int64 `json:"order_tax_amount"`
|
||||
OrderLines []legacyLine `json:"order_lines"`
|
||||
Customer *legacyPerson `json:"customer,omitempty"`
|
||||
BillingAddress *legacyAddr `json:"billing_address,omitempty"`
|
||||
}
|
||||
|
||||
type legacyLine struct {
|
||||
Reference string `json:"reference"`
|
||||
Name string `json:"name"`
|
||||
Quantity int32 `json:"quantity"`
|
||||
UnitPrice int64 `json:"unit_price"`
|
||||
TaxRate int32 `json:"tax_rate"`
|
||||
TotalAmount int64 `json:"total_amount"`
|
||||
TotalTaxAmount int64 `json:"total_tax_amount"`
|
||||
}
|
||||
|
||||
type legacyPerson struct {
|
||||
Email string `json:"email,omitempty"`
|
||||
}
|
||||
type legacyAddr struct {
|
||||
Email string `json:"email,omitempty"`
|
||||
GivenName string `json:"given_name,omitempty"`
|
||||
FamilyName string `json:"family_name,omitempty"`
|
||||
}
|
||||
|
||||
// legacyOrderID maps a legacy string order id to a stable grain id, so a
|
||||
// redelivered message resolves to the same grain (PlaceOrder then no-ops).
|
||||
func legacyOrderID(ref string) uint64 {
|
||||
h := fnv.New64a()
|
||||
_, _ = h.Write([]byte(ref))
|
||||
id := h.Sum64()
|
||||
if id == 0 {
|
||||
id = 1
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// ingestLegacyOrder records one legacy order as an event-sourced grain. It is
|
||||
// idempotent: if the order is already placed, PlaceOrder is rejected and we
|
||||
// stop (the order already exists). Testable without a broker.
|
||||
func ingestLegacyOrder(ctx context.Context, app *orderedApplier, body []byte) error {
|
||||
var lo legacyOrder
|
||||
if err := json.Unmarshal(body, &lo); err != nil {
|
||||
// ingestOrderFromQueue records one order from the queue. It uses the exact same
|
||||
// idempotent business logic as the HTTP endpoint.
|
||||
func ingestOrderFromQueue(ctx context.Context, s *server, body []byte) error {
|
||||
var req fromCheckoutReq
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
if lo.ID == "" || len(lo.OrderLines) == 0 {
|
||||
if len(req.Lines) == 0 {
|
||||
return nil // nothing actionable
|
||||
}
|
||||
id := legacyOrderID(lo.ID)
|
||||
ms := time.Now().UnixMilli()
|
||||
|
||||
po := &messages.PlaceOrder{
|
||||
OrderReference: lo.ID,
|
||||
Currency: lo.PurchaseCurrency,
|
||||
Locale: lo.Locale,
|
||||
Country: lo.PurchaseCountry,
|
||||
TotalAmount: lo.OrderAmount,
|
||||
TotalTax: lo.OrderTaxAmount,
|
||||
PlacedAtMs: ms,
|
||||
_, _, _, _, runErr, err := s.createOrderFromCheckoutInternal(ctx, &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if lo.Customer != nil {
|
||||
po.CustomerEmail = lo.Customer.Email
|
||||
if runErr != nil {
|
||||
return runErr
|
||||
}
|
||||
if po.CustomerEmail == "" && lo.BillingAddress != nil {
|
||||
po.CustomerEmail = lo.BillingAddress.Email
|
||||
po.CustomerName = lo.BillingAddress.GivenName + " " + lo.BillingAddress.FamilyName
|
||||
}
|
||||
for _, l := range lo.OrderLines {
|
||||
po.Lines = append(po.Lines, &messages.OrderLine{
|
||||
Reference: l.Reference, Sku: l.Reference, Name: l.Name,
|
||||
Quantity: l.Quantity, UnitPrice: l.UnitPrice, TaxRate: l.TaxRate,
|
||||
TotalAmount: l.TotalAmount, TotalTax: l.TotalTaxAmount,
|
||||
})
|
||||
}
|
||||
|
||||
if err := applyOne(ctx, app, id, po); err != nil {
|
||||
// Already placed (redelivery) or invalid — nothing more to do.
|
||||
return nil
|
||||
}
|
||||
// Record the externally-settled payment so the grain reaches "captured".
|
||||
ref := "legacy-" + lo.ID
|
||||
_ = applyOne(ctx, app, id, &messages.AuthorizePayment{Provider: "legacy", Amount: lo.OrderAmount, Reference: ref, AtMs: ms})
|
||||
_ = applyOne(ctx, app, id, &messages.CapturePayment{Provider: "legacy", Amount: lo.OrderAmount, Reference: ref, AtMs: ms})
|
||||
return nil
|
||||
}
|
||||
|
||||
// startLegacyIngest connects to AMQP and consumes "order-queue" until ctx is
|
||||
// done. Best-effort: a connection failure is logged and ingestion is skipped
|
||||
// (the HTTP checkout path keeps working regardless).
|
||||
func startLegacyIngest(ctx context.Context, amqpURL string, app *orderedApplier, logger *slog.Logger) {
|
||||
// startOrderIngest connects to AMQP and consumes "order-queue" until ctx is
|
||||
// done.
|
||||
func startOrderIngest(ctx context.Context, amqpURL string, s *server) {
|
||||
conn, err := amqp.Dial(amqpURL)
|
||||
if err != nil {
|
||||
logger.Warn("legacy order ingest disabled: amqp dial failed", "err", err)
|
||||
s.logger.Warn("order ingest disabled: amqp dial failed", "err", err)
|
||||
return
|
||||
}
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
logger.Warn("legacy order ingest disabled: channel failed", "err", err)
|
||||
s.logger.Warn("order ingest disabled: channel failed", "err", err)
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
q, err := ch.QueueDeclare("order-queue", false, false, false, false, nil)
|
||||
if err != nil {
|
||||
logger.Warn("legacy order ingest disabled: queue declare failed", "err", err)
|
||||
s.logger.Warn("order ingest disabled: queue declare failed", "err", err)
|
||||
_ = ch.Close()
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
msgs, err := ch.Consume(q.Name, "order-service", true, false, false, false, nil)
|
||||
if err != nil {
|
||||
logger.Warn("legacy order ingest disabled: consume failed", "err", err)
|
||||
s.logger.Warn("order ingest disabled: consume failed", "err", err)
|
||||
_ = ch.Close()
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
logger.Info("ingesting legacy orders from order-queue")
|
||||
s.logger.Info("ingesting orders from order-queue")
|
||||
go func() {
|
||||
defer conn.Close()
|
||||
defer ch.Close()
|
||||
@@ -211,11 +128,11 @@ func startLegacyIngest(ctx context.Context, amqpURL string, app *orderedApplier,
|
||||
return
|
||||
case d, ok := <-msgs:
|
||||
if !ok {
|
||||
logger.Warn("legacy order ingest: channel closed")
|
||||
s.logger.Warn("order ingest: channel closed")
|
||||
return
|
||||
}
|
||||
if err := ingestLegacyOrder(ctx, app, d.Body); err != nil {
|
||||
logger.Error("legacy order ingest failed", "err", err)
|
||||
if err := ingestOrderFromQueue(ctx, s, d.Body); err != nil {
|
||||
s.logger.Error("order queue ingest failed", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+50
-12
@@ -3,14 +3,19 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"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/order"
|
||||
)
|
||||
|
||||
func testApplier(t *testing.T) *orderedApplier {
|
||||
func testServer(t *testing.T) *server {
|
||||
t.Helper()
|
||||
reg := actor.NewMutationRegistry()
|
||||
order.RegisterMutations(reg)
|
||||
@@ -32,23 +37,56 @@ func testApplier(t *testing.T) *orderedApplier {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
return &orderedApplier{pool: pool, storage: storage}
|
||||
|
||||
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)
|
||||
engine := flow.NewEngine(freg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
|
||||
def, err := order.EmbeddedFlow("place-and-pay")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
idem, err := idempotency.Open(filepath.Join(t.TempDir(), "idem.log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return &server{
|
||||
pool: pool, applier: applier, storage: storage, reg: reg,
|
||||
engine: engine, freg: freg,
|
||||
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)),
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestLegacyOrder(t *testing.T) {
|
||||
app := testApplier(t)
|
||||
func TestIngestOrderFromQueue(t *testing.T) {
|
||||
s := testServer(t)
|
||||
ctx := context.Background()
|
||||
body := []byte(`{
|
||||
"order_id":"K-1","purchase_currency":"SEK","purchase_country":"se","locale":"sv-SE",
|
||||
"order_amount":15000,"order_tax_amount":3000,
|
||||
"order_lines":[{"reference":"l1","name":"Widget","quantity":1,"unit_price":15000,"tax_rate":25,"total_amount":15000,"total_tax_amount":3000}]
|
||||
"checkoutId":"K-1","idempotencyKey":"checkout-K-1","cartId":"cart-1",
|
||||
"currency":"SEK","country":"SE","customerEmail":"customer@example.com",
|
||||
"lines":[{"reference":"l1","sku":"l1","name":"Widget","quantity":1,"unitPrice":15000,"taxRate":25}],
|
||||
"payment":{"provider":"klarna","reference":"ref-123","amount":15000}
|
||||
}`)
|
||||
|
||||
if err := ingestLegacyOrder(ctx, app, body); err != nil {
|
||||
if err := ingestOrderFromQueue(ctx, s, body); err != nil {
|
||||
t.Fatalf("ingest: %v", err)
|
||||
}
|
||||
id := legacyOrderID("K-1")
|
||||
g, err := app.Get(ctx, id)
|
||||
|
||||
// Retrieve the created order grain. The new order ID is recorded in idempotency.Store
|
||||
id, ok := s.idem.Get("checkout-K-1")
|
||||
if !ok {
|
||||
t.Fatalf("expected order ID to be stored in idempotency cache")
|
||||
}
|
||||
|
||||
g, err := s.applier.Get(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -60,10 +98,10 @@ func TestIngestLegacyOrder(t *testing.T) {
|
||||
}
|
||||
|
||||
// Redelivery must be idempotent — no double capture, no new payment.
|
||||
if err := ingestLegacyOrder(ctx, app, body); err != nil {
|
||||
if err := ingestOrderFromQueue(ctx, s, body); err != nil {
|
||||
t.Fatalf("re-ingest: %v", err)
|
||||
}
|
||||
g2, _ := app.Get(ctx, id)
|
||||
g2, _ := s.applier.Get(ctx, id)
|
||||
if g2.CapturedAmount != 15000 {
|
||||
t.Fatalf("redelivery double-captured: %d", g2.CapturedAmount)
|
||||
}
|
||||
|
||||
+146
-4
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
||||
@@ -100,7 +101,7 @@ type fulfillReq struct {
|
||||
}
|
||||
|
||||
func (s *server) handleFulfill(w http.ResponseWriter, r *http.Request) {
|
||||
id, _, ok := s.loadOrder(w, r)
|
||||
id, g, ok := s.loadOrder(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -110,11 +111,72 @@ func (s *server) handleFulfill(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
fid, _ := order.NewOrderId()
|
||||
|
||||
carrier := req.Carrier
|
||||
trackingNumber := req.TrackingNumber
|
||||
trackingURI := req.TrackingURI
|
||||
|
||||
// Integration with go-shipping:
|
||||
if carrier == "" && g.CartId != "" {
|
||||
shippingURL := os.Getenv("SHIPPING_URL")
|
||||
if shippingURL == "" {
|
||||
shippingURL = "http://localhost:8080"
|
||||
}
|
||||
// Query go-shipping for cached options
|
||||
url := fmt.Sprintf("%s/api/shipping-options/%s", shippingURL, g.CartId)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err == nil {
|
||||
resp, err := http.DefaultClient.Do(httpReq)
|
||||
if err == nil && resp.StatusCode == http.StatusOK {
|
||||
var groups []struct {
|
||||
Type string `json:"type"`
|
||||
DefaultOption *struct {
|
||||
BookingInstructions struct {
|
||||
DeliveryOptionID string `json:"deliveryOptionId"`
|
||||
ServiceCode string `json:"serviceCode"`
|
||||
} `json:"bookingInstructions"`
|
||||
DescriptiveTexts struct {
|
||||
Checkout struct {
|
||||
Title string `json:"title"`
|
||||
} `json:"checkout"`
|
||||
} `json:"descriptiveTexts"`
|
||||
} `json:"defaultOption"`
|
||||
}
|
||||
if json.NewDecoder(resp.Body).Decode(&groups) == nil && len(groups) > 0 {
|
||||
g0 := groups[0]
|
||||
carrier = g0.Type
|
||||
if g0.DefaultOption != nil {
|
||||
opt := g0.DefaultOption
|
||||
if opt.DescriptiveTexts.Checkout.Title != "" {
|
||||
carrier = opt.DescriptiveTexts.Checkout.Title
|
||||
}
|
||||
trackingNumber = "SE-" + opt.BookingInstructions.DeliveryOptionID
|
||||
trackingURI = "https://www.postnord.se/en/our-tools/track-and-trace?shipmentId=" + trackingNumber
|
||||
}
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if carrier == "" {
|
||||
carrier = "postnord"
|
||||
}
|
||||
if trackingNumber == "" {
|
||||
trackingNumber = "mock-track-" + fid.String()
|
||||
}
|
||||
if trackingURI == "" {
|
||||
trackingURI = "https://tracking.postnord.com/?id=" + trackingNumber
|
||||
}
|
||||
|
||||
msg := &messages.CreateFulfillment{
|
||||
Id: "f_" + fid.String(),
|
||||
Carrier: req.Carrier,
|
||||
TrackingNumber: req.TrackingNumber,
|
||||
TrackingUri: req.TrackingURI,
|
||||
Carrier: carrier,
|
||||
TrackingNumber: trackingNumber,
|
||||
TrackingUri: trackingURI,
|
||||
AtMs: nowMs(),
|
||||
}
|
||||
for _, l := range req.Lines {
|
||||
@@ -123,6 +185,86 @@ func (s *server) handleFulfill(w http.ResponseWriter, r *http.Request) {
|
||||
s.applyAndRespond(w, r, id, msg)
|
||||
}
|
||||
|
||||
type exchangeReq struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
ReturnLines []struct {
|
||||
Reference string `json:"reference"`
|
||||
Quantity int32 `json:"quantity"`
|
||||
} `json:"returnLines"`
|
||||
NewLines []struct {
|
||||
Reference string `json:"reference"`
|
||||
Sku string `json:"sku"`
|
||||
Name string `json:"name"`
|
||||
Quantity int32 `json:"quantity"`
|
||||
UnitPrice int64 `json:"unitPrice"`
|
||||
TaxRate int32 `json:"taxRate"`
|
||||
TotalAmount int64 `json:"totalAmount"`
|
||||
TotalTax int64 `json:"totalTax"`
|
||||
} `json:"newLines"`
|
||||
}
|
||||
|
||||
func (s *server) handleExchange(w http.ResponseWriter, r *http.Request) {
|
||||
id, _, ok := s.loadOrder(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req exchangeReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
exId, _ := order.NewOrderId()
|
||||
retId, _ := order.NewOrderId()
|
||||
msg := &messages.RequestExchange{
|
||||
Id: "ex_" + exId.String(),
|
||||
ReturnId: "r_" + retId.String(),
|
||||
Reason: req.Reason,
|
||||
AtMs: nowMs(),
|
||||
}
|
||||
for _, l := range req.ReturnLines {
|
||||
msg.ReturnLines = append(msg.ReturnLines, &messages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity})
|
||||
}
|
||||
for _, l := range req.NewLines {
|
||||
msg.NewLines = append(msg.NewLines, &messages.OrderLine{
|
||||
Reference: l.Reference,
|
||||
Sku: l.Sku,
|
||||
Name: l.Name,
|
||||
Quantity: l.Quantity,
|
||||
UnitPrice: l.UnitPrice,
|
||||
TaxRate: l.TaxRate,
|
||||
TotalAmount: l.TotalAmount,
|
||||
TotalTax: l.TotalTax,
|
||||
})
|
||||
}
|
||||
s.applyAndRespond(w, r, id, msg)
|
||||
}
|
||||
|
||||
type editDetailsReq struct {
|
||||
ShippingAddress json.RawMessage `json:"shippingAddress,omitempty"`
|
||||
BillingAddress json.RawMessage `json:"billingAddress,omitempty"`
|
||||
ShippingPrice int64 `json:"shippingPrice,omitempty"`
|
||||
}
|
||||
|
||||
func (s *server) handleEditDetails(w http.ResponseWriter, r *http.Request) {
|
||||
id, _, ok := s.loadOrder(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req editDetailsReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
msg := &messages.EditOrderDetails{
|
||||
ShippingAddress: req.ShippingAddress,
|
||||
BillingAddress: req.BillingAddress,
|
||||
ShippingPrice: req.ShippingPrice,
|
||||
AtMs: nowMs(),
|
||||
}
|
||||
s.applyAndRespond(w, r, id, msg)
|
||||
}
|
||||
|
||||
|
||||
func (s *server) handleComplete(w http.ResponseWriter, r *http.Request) {
|
||||
id, _, ok := s.loadOrder(w, r)
|
||||
if !ok {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -18,7 +19,7 @@ import (
|
||||
type fromCheckoutReq struct {
|
||||
CheckoutId string `json:"checkoutId"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
Flow string `json:"flow,omitempty"`
|
||||
Flow string `json:"flow,omitempty"`
|
||||
|
||||
CartId string `json:"cartId"`
|
||||
Currency string `json:"currency"`
|
||||
@@ -55,101 +56,95 @@ func (s *server) handleFromCheckout(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// ── Idempotency check ─────────────────────────────────────────────
|
||||
// Lock the key for the whole check→create→record sequence so a concurrent
|
||||
// retry with the same key can't race past the check and create a second
|
||||
// order. The store is durable, so this also holds across a restart.
|
||||
ordID, flowRes, grain, exists, runErr, err := s.createOrderFromCheckoutInternal(r.Context(), &req)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
if exists {
|
||||
writeJSON(w, http.StatusConflict, map[string]any{
|
||||
"orderId": order.OrderId(ordID).String(),
|
||||
"order": grain,
|
||||
"existing": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
status := http.StatusCreated
|
||||
if runErr != nil {
|
||||
status = http.StatusPaymentRequired
|
||||
}
|
||||
|
||||
writeJSON(w, status, map[string]any{
|
||||
"orderId": order.OrderId(ordID).String(),
|
||||
"flow": flowRes,
|
||||
"order": grain,
|
||||
})
|
||||
}
|
||||
|
||||
// createOrderFromCheckoutInternal runs the core idempotent checkout-to-order logic.
|
||||
// It returns (orderID, flowResult, grain, exists, runErr, err).
|
||||
// - exists is true if the order already existed (idempotency hit).
|
||||
// - runErr is the flow run error (payment flow failed, e.g. compensating was run).
|
||||
// - err is a structural/storage error.
|
||||
func (s *server) createOrderFromCheckoutInternal(ctx context.Context, req *fromCheckoutReq) (uint64, *flow.Result, *order.OrderGrain, bool, error, error) {
|
||||
if req.IdempotencyKey != "" {
|
||||
unlock := s.idem.Lock(req.IdempotencyKey)
|
||||
defer unlock()
|
||||
if existingID, ok := s.idem.Get(req.IdempotencyKey); ok {
|
||||
// The order already exists — return it.
|
||||
g, err := s.applier.Get(r.Context(), existingID)
|
||||
g, err := s.applier.Get(ctx, existingID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, fmt.Errorf("idempotent lookup: %w", err))
|
||||
return
|
||||
return 0, nil, nil, false, nil, fmt.Errorf("idempotent lookup: %w", err)
|
||||
}
|
||||
writeJSON(w, http.StatusConflict, map[string]any{
|
||||
"orderId": order.OrderId(existingID).String(),
|
||||
"order": g,
|
||||
"existing": true,
|
||||
})
|
||||
return
|
||||
return existingID, nil, g, true, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ── Create the order grain ────────────────────────────────────────
|
||||
id, err := order.NewOrderId()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, fmt.Errorf("new order id: %w", err))
|
||||
return
|
||||
return 0, nil, nil, false, nil, fmt.Errorf("new order id: %w", err)
|
||||
}
|
||||
ordID := uint64(id)
|
||||
|
||||
// Build PlaceOrder from the same lineReq format the direct checkout uses.
|
||||
po := buildFromCheckoutPlaceOrder(id, &req)
|
||||
|
||||
// Build a passthrough provider for the externally-settled payment.
|
||||
po := buildFromCheckoutPlaceOrder(id, req)
|
||||
provider := order.NewPassthroughProvider(req.Payment.Provider, req.Payment.Reference, req.Payment.Amount)
|
||||
|
||||
// Run the place-and-pay flow with the passthrough provider. Since the
|
||||
// payment is already settled, this records the authorization and capture
|
||||
// without any external call.
|
||||
flowName := req.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
|
||||
return 0, nil, nil, false, nil, fmt.Errorf("unknown flow %q", flowName)
|
||||
}
|
||||
|
||||
// Create a per-request flow registry so the authorize/capture actions use
|
||||
// the passthrough provider for this specific order. RegisterFlowActions
|
||||
// registers all order lifecycle actions, overriding the default provider.
|
||||
freg := flow.NewRegistry()
|
||||
flow.RegisterBuiltinHooks(freg)
|
||||
order.RegisterFlowActions(freg, s.applier, provider)
|
||||
order.RegisterEmitHook(freg, nil)
|
||||
|
||||
engine := flow.NewEngine(freg, s.logger)
|
||||
|
||||
st := flow.NewState(ordID, s.logger)
|
||||
st.Vars[order.PlaceOrderVar] = po
|
||||
|
||||
res, runErr := engine.Run(r.Context(), def, st)
|
||||
grain, getErr := s.applier.Get(r.Context(), ordID)
|
||||
res, runErr := engine.Run(ctx, def, st)
|
||||
grain, getErr := s.applier.Get(ctx, ordID)
|
||||
if getErr != nil {
|
||||
writeErr(w, http.StatusInternalServerError, getErr)
|
||||
return
|
||||
return 0, nil, nil, false, nil, fmt.Errorf("get order grain: %w", getErr)
|
||||
}
|
||||
|
||||
// On failure the flow should have compensated (voided + cancelled). Return
|
||||
// the failed order with the flow trace so the checkout service can decide
|
||||
// how to proceed (e.g. alert operator, retry with a new idempotency key).
|
||||
status := http.StatusCreated
|
||||
if runErr != nil {
|
||||
status = http.StatusPaymentRequired
|
||||
s.logger.Warn("from-checkout flow failed", "orderId", id.String(), "err", runErr)
|
||||
}
|
||||
|
||||
// Record the idempotency key durably only on success, so retries (incl. after
|
||||
// a restart) return the captured order. A failed flow (402) is deliberately
|
||||
// NOT recorded: the failed grain is terminal, and a retry with the same key
|
||||
// should re-attempt rather than be handed back a dead order as a 409 hit
|
||||
// (which the checkout client treats as a successful, existing order).
|
||||
if req.IdempotencyKey != "" && runErr == nil {
|
||||
if err := s.idem.Put(req.IdempotencyKey, ordID); err != nil {
|
||||
s.logger.Error("idempotency record failed", "key", req.IdempotencyKey, "orderId", id.String(), "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, status, map[string]any{
|
||||
"orderId": id.String(),
|
||||
"flow": res,
|
||||
"order": grain,
|
||||
})
|
||||
return ordID, res, grain, false, runErr, nil
|
||||
}
|
||||
|
||||
// buildFromCheckoutPlaceOrder converts a from-checkout request into a PlaceOrder
|
||||
@@ -169,11 +164,7 @@ func buildFromCheckoutPlaceOrder(id order.OrderId, req *fromCheckoutReq) *messag
|
||||
var total, totalTax int64
|
||||
for _, l := range req.Lines {
|
||||
lineTotal := l.UnitPrice * int64(l.Quantity)
|
||||
var lineTax int64
|
||||
if l.TaxRate > 0 {
|
||||
// inc-vat -> tax portion = total * rate / (100 + rate)
|
||||
lineTax = lineTotal * int64(l.TaxRate) / int64(100+l.TaxRate)
|
||||
}
|
||||
lineTax := order.ComputeTax(lineTotal, int(l.TaxRate))
|
||||
total += lineTotal
|
||||
totalTax += lineTax
|
||||
po.Lines = append(po.Lines, &messages.OrderLine{
|
||||
|
||||
+30
-11
@@ -41,6 +41,7 @@ type server struct {
|
||||
freg *flow.Registry
|
||||
flows *flowStore
|
||||
provider order.PaymentProvider
|
||||
taxProvider order.TaxProvider
|
||||
defaultFlow string
|
||||
dataDir string
|
||||
idem *idempotency.Store
|
||||
@@ -134,9 +135,12 @@ func main() {
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -150,12 +154,14 @@ func main() {
|
||||
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).
|
||||
// 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)
|
||||
@@ -183,10 +189,9 @@ func main() {
|
||||
mux.HandleFunc("GET /sagas/flows/{name}", s.handleGetFlow)
|
||||
mux.HandleFunc("PUT /sagas/flows/{name}", s.handleSaveFlow)
|
||||
|
||||
// Ingest legacy checkout orders (Klarna/Adyen → order-queue) so this service
|
||||
// is the single source of orders, superseding go-order-manager.
|
||||
// Ingest checkout orders from order-queue (Klarna/Adyen fallback).
|
||||
if amqpURL != "" {
|
||||
startLegacyIngest(context.Background(), amqpURL, applier, logger)
|
||||
startOrderIngest(context.Background(), amqpURL, s)
|
||||
}
|
||||
|
||||
logger.Info("order service listening", "addr", addr, "data", dataDir, "flows", flowsDir)
|
||||
@@ -292,7 +297,7 @@ func (s *server) handleCheckout(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
po := buildPlaceOrder(id, &req)
|
||||
po := s.buildPlaceOrder(id, &req)
|
||||
|
||||
st := flow.NewState(uint64(id), s.logger)
|
||||
st.Vars[order.PlaceOrderVar] = po
|
||||
@@ -320,7 +325,8 @@ func (s *server) handleCheckout(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// buildPlaceOrder converts a checkout request into the PlaceOrder event,
|
||||
// computing per-line and order totals (inc-vat) from the lines.
|
||||
func buildPlaceOrder(id order.OrderId, req *checkoutReq) *messages.PlaceOrder {
|
||||
// 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,
|
||||
@@ -336,11 +342,7 @@ func buildPlaceOrder(id order.OrderId, req *checkoutReq) *messages.PlaceOrder {
|
||||
var total, totalTax int64
|
||||
for _, l := range req.Lines {
|
||||
lineTotal := l.UnitPrice * int64(l.Quantity)
|
||||
var lineTax int64
|
||||
if l.TaxRate > 0 {
|
||||
// inc-vat -> tax portion = total * rate / (100 + rate)
|
||||
lineTax = lineTotal * int64(l.TaxRate) / int64(100+l.TaxRate)
|
||||
}
|
||||
lineTax := s.taxProvider.ComputeTax(lineTotal, int(l.TaxRate))
|
||||
total += lineTotal
|
||||
totalTax += lineTax
|
||||
po.Lines = append(po.Lines, &messages.OrderLine{
|
||||
@@ -450,10 +452,27 @@ func (s *server) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// --- 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() order.TaxProvider {
|
||||
provider := os.Getenv("TAX_PROVIDER")
|
||||
switch provider {
|
||||
case "static":
|
||||
return order.NewStaticTaxProvider()
|
||||
case "nordic":
|
||||
return order.NewNordicTaxProvider(envOr("TAX_DEFAULT_COUNTRY", "SE"))
|
||||
default:
|
||||
return order.NewNordicTaxProvider(envOr("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 == "" {
|
||||
|
||||
Reference in New Issue
Block a user