package main import ( "context" "encoding/json" "fmt" "git.k6n.net/mats/go-cart-actor/pkg/checkout" profile "git.k6n.net/mats/go-cart-actor/pkg/profile" messages "git.k6n.net/mats/go-cart-actor/proto/checkout" profileMessages "git.k6n.net/mats/go-cart-actor/proto/profile" "google.golang.org/protobuf/types/known/timestamppb" ) // settledPayment carries the provider-agnostic facts about an already-settled // payment. Each payment integration (Klarna push, Adyen CAPTURE notification, // …) fills this in from its own callback and hands it to // createOrderFromSettledCheckout, so the from-checkout request is identical // regardless of which provider settled the payment. type settledPayment struct { Provider string // "klarna" | "adyen" Reference string // processor / PSP reference Amount int64 // minor units, as charged Currency string Country string Locale string } // buildOrderLinesFromGrain converts the checkout grain's cart items and delivery // selections into from-checkout order lines. Shared by every provider so the // order payload is built the same way no matter who settled the payment. func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine { hasFreeShipping := false if grain.CartState != nil { for _, ap := range grain.CartState.AppliedPromotions { if ap.Type == "free_shipping" && !ap.Pending { hasFreeShipping = true break } } } lines := make([]OrderLine, 0, len(grain.CartState.Items)+len(grain.Deliveries)) for _, it := range grain.CartState.Items { if it == nil { continue } // Carry the per-item store as the inventory commit location; empty when // no store (commit then falls back to the order country / central stock). location := "" if it.StoreId != nil { location = *it.StoreId } lines = append(lines, OrderLine{ Reference: it.Sku, Sku: it.Sku, Name: it.Meta.Name, Quantity: int32(it.Quantity), UnitPrice: it.Price.IncVat.Int64(), // CartItem.Tax and OrderLine.TaxRate share the platform basis-point scale // (2500 = 25%), so the rate passes through unchanged. TaxRate: int32(it.Tax), DropShip: it.DropShip, Location: location, }) } for _, d := range grain.Deliveries { if d == nil { continue } unitPrice := d.Price.IncVat.Int64() if hasFreeShipping { unitPrice = 0 } if unitPrice <= 0 && !hasFreeShipping { continue } lines = append(lines, OrderLine{ Reference: d.Provider, Sku: d.Provider, Name: "Delivery", Quantity: 1, UnitPrice: unitPrice, TaxRate: 2500, // 25% in basis points }) } // Reflected applied promotions as negative discount lines if grain.CartState != nil { for _, ap := range grain.CartState.AppliedPromotions { if ap.Pending { continue } discountVal := int64(0) if ap.Discount != nil { discountVal = ap.Discount.IncVat.Int64() } if discountVal <= 0 { continue } lines = append(lines, OrderLine{ Reference: "promo-" + ap.PromotionId, Sku: "promo-" + ap.PromotionId, Name: "Promotion: " + ap.Name, Quantity: 1, UnitPrice: -discountVal, TaxRate: 2500, // default standard tax rate (25%) in basis points }) } } return lines } // createOrderFromSettledCheckout sends the from-checkout request for a settled // checkout (any provider) and, on success, records the returned order id on the // checkout grain via the OrderCreated mutation so the backoffice can link // checkout → order. // // It is idempotent: the order service dedupes on "checkout-{checkoutId}", so // repeated provider callbacks (Klarna re-push, retried Adyen CAPTURE) return the // existing order rather than creating a duplicate. // // If the HTTP call fails or is not configured, it falls back to publishing the // exact same payload to RabbitMQ via s.orderHandler.OrderCompleted(body). func createOrderFromSettledCheckout(ctx context.Context, s *CheckoutPoolServer, grain *checkout.CheckoutGrain, pay settledPayment) (*CreateOrderResult, error) { if grain.CartState == nil { return nil, fmt.Errorf("from-checkout: checkout %s has no cart state", grain.Id) } var customerEmail, customerName string if grain.ContactDetails != nil { if grain.ContactDetails.Email != nil { customerEmail = *grain.ContactDetails.Email } if grain.ContactDetails.Name != nil { customerName = *grain.ContactDetails.Name } } req := CreateOrderReq{ CheckoutId: grain.Id.String(), IdempotencyKey: "checkout-" + grain.Id.String(), CartId: grain.CartId.String(), Currency: pay.Currency, Locale: pay.Locale, Country: pay.Country, CustomerEmail: customerEmail, CustomerName: customerName, Lines: buildOrderLinesFromGrain(grain), } req.Payment.Provider = pay.Provider req.Payment.Reference = pay.Reference req.Payment.Amount = pay.Amount var createErr error if s.orderClient != nil { result, err := s.orderClient.CreateOrder(ctx, req, "") if err == nil { _ = s.ApplyAnywhere(ctx, grain.Id, &messages.OrderCreated{ OrderId: result.OrderId, Status: "completed", CreatedAt: timestamppb.Now(), }) if grain.CartState != nil && grain.CartState.UserId != "" { if pid, ok := profile.ParseProfileId(grain.CartState.UserId); ok { _ = s.ApplyAnywhere(ctx, checkout.CheckoutId(pid), &profileMessages.LinkOrder{ OrderReference: result.OrderId, CartId: uint64(grain.CartId), Status: "completed", }) } } return result, nil } createErr = err logger.WarnContext(ctx, "from-checkout HTTP call failed; falling back to AMQP", "err", err, "checkoutId", grain.Id.String()) } // Fallback to AMQP if s.orderHandler != nil { body, err := json.Marshal(req) if err != nil { return nil, fmt.Errorf("failed to marshal order fallback: %w", err) } if pubErr := s.orderHandler.OrderCompleted(body); pubErr != nil { if createErr != nil { return nil, fmt.Errorf("AMQP fallback failed: %w (HTTP error: %v)", pubErr, createErr) } return nil, fmt.Errorf("AMQP fallback failed: %w", pubErr) } logger.InfoContext(ctx, "order request published to AMQP successfully as fallback", "checkoutId", grain.Id.String()) return &CreateOrderResult{ OrderId: "queued-" + grain.Id.String(), }, nil } if createErr != nil { return nil, createErr } return nil, fmt.Errorf("neither order client nor AMQP order handler configured") }