192 lines
5.9 KiB
Go
192 lines
5.9 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
|
messages "git.k6n.net/mats/go-cart-actor/proto/checkout"
|
|
"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(),
|
|
})
|
|
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")
|
|
}
|