Skip to content

Commit

Permalink
feat(product): introduce multiple loyalty prices (#554)
Browse files Browse the repository at this point in the history
* feat(product): introduce multiple loyalty prices

* feat(product): lint fixes

* feat(product): add changelog

* feat(product): review fixes

* feat(product): remove redundant fields to preserve model

* feat(product): adapt generateLoyaltyChargeSplit to use single loyalty

* feat(product): adapt GetLoyaltyPriceByType to use single loyalty

* feat(product): regenerate everything

* feat(product): fix tests

* feat(product): fix linter issues

* feat(product): fix changelog

* feat(cart): sneak delivery code in context

* feat(product): fix fake products

* feat(product): return GetLoyaltyPriceByType

* feat(product): brush up

* feat(product): add active price to GetLoyaltyPriceByType

* feat(product): make activeLoyaltyPrice a pointer

Co-authored-by: Carsten Dietrich <3203968+carstendietrich@users.noreply.github.com>

* fix(product): loyalty rate calculations now follow common rounding rules to be more exact

* chore: fix loyalty value

* feat(product): move getValidLoyaltyCharge out of generateLoyaltyChargeSplit

* feat(product): fix comment

---------

Co-authored-by: Carsten Dietrich <3203968+carstendietrich@users.noreply.github.com>
Co-authored-by: Thorsten Essig <thorsten.essig@omnevo.net>
  • Loading branch information
3 people authored Apr 15, 2024
1 parent 42bcf09 commit 86c633f
Show file tree
Hide file tree
Showing 18 changed files with 601 additions and 153 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
**price**
* **Breaking:** Introduce currency library http://github.com/Rhymond/go-money for more flexible rounding. All currency codes should comply to ISO4217 from now on.

**product**
* Introduced multiple loyalty prices for one product by adding AvailablePrices to ProductLoyalty.
* Added ActiveLoyaltyPrice to Saleable and adapted charges generation to use it instead of array

## v3.9.0

**search**
Expand Down
22 changes: 19 additions & 3 deletions cart/domain/decorator/cartDecorator.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ type (
Group string
}

ctxKey struct{}
cartCtxKey struct{}
deliveryCtxKey struct{}
)

// Inject dependencies
Expand All @@ -68,10 +69,14 @@ func (df *DecoratedCartFactory) Create(ctx context.Context, cart cartDomain.Cart

decoratedCart := DecoratedCart{Cart: cart, Logger: df.logger}

contextWithCart := context.WithValue(ctx, cartCtxKey{}, cart)

for _, d := range cart.Deliveries {
contextWithDeliveryCode := context.WithValue(contextWithCart, deliveryCtxKey{}, d.DeliveryInfo.Code)

decoratedCart.DecoratedDeliveries = append(decoratedCart.DecoratedDeliveries, DecoratedDelivery{
Delivery: d,
DecoratedItems: df.CreateDecorateCartItems(context.WithValue(ctx, ctxKey{}, cart), d.Cartitems),
DecoratedItems: df.CreateDecorateCartItems(contextWithDeliveryCode, d.Cartitems),
logger: df.logger,
})
}
Expand Down Expand Up @@ -324,9 +329,20 @@ func CartFromDecoratedCartFactoryContext(ctx context.Context) *cartDomain.Cart {
ctx, span := trace.StartSpan(ctx, "cart/CartFromDecoratedCartFactoryContext")
defer span.End()

if cart, ok := ctx.Value(ctxKey{}).(cartDomain.Cart); ok {
if cart, ok := ctx.Value(cartCtxKey{}).(cartDomain.Cart); ok {
return &cart
}

return nil
}

func DeliveryCodeFromDecoratedCartFactoryContext(ctx context.Context) string {
ctx, span := trace.StartSpan(ctx, "cart/DeliveryCodeFromDecoratedCartFactoryContext")
defer span.End()

if deliveryCode, ok := ctx.Value(deliveryCtxKey{}).(string); ok {
return deliveryCode
}

return ""
}
3 changes: 3 additions & 0 deletions docs/openapi/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -2339,6 +2339,9 @@ const docTemplate = `{
"domain.SimpleProduct": {
"type": "object",
"properties": {
"ActiveLoyaltyPrice": {
"$ref": "#/definitions/domain.LoyaltyPriceInfo"
},
"ActivePrice": {
"$ref": "#/definitions/domain.PriceInfo"
},
Expand Down
3 changes: 3 additions & 0 deletions docs/openapi/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -2331,6 +2331,9 @@
"domain.SimpleProduct": {
"type": "object",
"properties": {
"ActiveLoyaltyPrice": {
"$ref": "#/definitions/domain.LoyaltyPriceInfo"
},
"ActivePrice": {
"$ref": "#/definitions/domain.PriceInfo"
},
Expand Down
2 changes: 2 additions & 0 deletions docs/openapi/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -838,6 +838,8 @@ definitions:
type: object
domain.SimpleProduct:
properties:
ActiveLoyaltyPrice:
$ref: '#/definitions/domain.LoyaltyPriceInfo'
ActivePrice:
$ref: '#/definitions/domain.PriceInfo'
Attributes:
Expand Down
158 changes: 86 additions & 72 deletions product/domain/productBasics.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,12 @@ type (

// Saleable are properties required for being selled
Saleable struct {
IsSaleable bool
SaleableFrom time.Time
SaleableTo time.Time
ActivePrice PriceInfo
AvailablePrices []PriceInfo
IsSaleable bool
SaleableFrom time.Time
SaleableTo time.Time
ActivePrice PriceInfo
AvailablePrices []PriceInfo
ActiveLoyaltyPrice *LoyaltyPriceInfo
// LoyaltyPrices holds optional infos for products that can be paid in a loyalty program
LoyaltyPrices []LoyaltyPriceInfo
// LoyaltyEarnings holds optional infos about potential loyalty earnings
Expand Down Expand Up @@ -415,13 +416,18 @@ func (p Saleable) IsSaleableNow() bool {
return false
}

// GetLoyaltyPriceByType returns the loyaltyentry that matches the type
// GetLoyaltyPriceByType returns first encountered loyalty price with this type
func (p Saleable) GetLoyaltyPriceByType(ltype string) (*LoyaltyPriceInfo, bool) {
if p.ActiveLoyaltyPrice != nil && p.ActiveLoyaltyPrice.Type == ltype {
return p.ActiveLoyaltyPrice, true
}

for _, lp := range p.LoyaltyPrices {
if lp.Type == ltype {
return &lp, true
}
}

return nil, false
}

Expand All @@ -443,92 +449,97 @@ func (p Saleable) generateLoyaltyChargeSplit(valuedPriceToPay *priceDomain.Price
requiredCharges := make(map[string]priceDomain.Charge)
remainingMainChargeValue := valuedPriceToPay.Amount()

// getLoyaltyCharge - private func that returns the loyaltyCharge of the given type. making sure the currentlyRemainingMainChargeValue is not exceeded
getValidLoyaltyCharge := func(loyaltyAmountWishedToSpent big.Float, loyaltyPrice LoyaltyPriceInfo, chargeType string, currentlyRemainingMainChargeValue *big.Float) priceDomain.Charge {
loyaltyCurrency := loyaltyPrice.GetFinalPrice().Currency()
rateLoyaltyFinalPriceToRealFinalPrice := loyaltyPrice.GetRate(p.ActivePrice.GetFinalPrice())
maximumPossibleLoyaltyValue := big.NewFloat(0.0)
if currentlyRemainingMainChargeValue.Cmp(big.NewFloat(0.0)) != 0 {
maximumPossibleLoyaltyValue = new(big.Float).Quo(currentlyRemainingMainChargeValue, &rateLoyaltyFinalPriceToRealFinalPrice)
}

maximumPossibleLoyaltyPrice := priceDomain.NewFromBigFloat(*maximumPossibleLoyaltyValue, loyaltyCurrency).GetPayable()

if loyaltyAmountWishedToSpent.Cmp(maximumPossibleLoyaltyValue) > 0 {
loyaltyAmountWishedToSpent = *maximumPossibleLoyaltyValue
}
valuedLoyalityPrice := priceDomain.NewFromBigFloat(*new(big.Float).Mul(&rateLoyaltyFinalPriceToRealFinalPrice, &loyaltyAmountWishedToSpent), valuedPriceToPay.Currency()).GetPayable()
if maximumPossibleLoyaltyPrice.Amount().Cmp(&loyaltyAmountWishedToSpent) == 0 {
// If the wish equals the rounded maximum - we need to use the complete remaining value
valuedLoyalityPrice = priceDomain.NewFromBigFloat(*currentlyRemainingMainChargeValue, valuedPriceToPay.Currency())
}
return priceDomain.Charge{
Price: priceDomain.NewFromBigFloat(loyaltyAmountWishedToSpent, loyaltyCurrency).GetPayable(),
Type: chargeType,
Value: valuedLoyalityPrice,
}
if p.ActiveLoyaltyPrice == nil || !p.ActiveLoyaltyPrice.GetFinalPrice().IsPositive() {
return buildCharges(requiredCharges, *remainingMainChargeValue, *valuedPriceToPay)
}

for _, loyaltyPrice := range p.LoyaltyPrices {
chargeType := loyaltyPrice.Type
if chargeType == "" {
continue
}
// loyaltyAmountToSpent - set as default without potential wish the minimum
loyaltyAmountToSpent := p.ActiveLoyaltyPrice.getMin(qty)

if !loyaltyPrice.GetFinalPrice().IsPositive() {
continue
}

// loyaltyAmountToSpent - set as default without potential wish the minimum
loyaltyAmountToSpent := loyaltyPrice.getMin(qty)

// check if the minimum points should be ignored, if so minimum will be set to 0
if ignoreMin {
loyaltyAmountToSpent = *big.NewFloat(0.0)
}
// check if the minimum points should be ignored, if so minimum will be set to 0
if ignoreMin {
loyaltyAmountToSpent = *big.NewFloat(0.0)
}

if loyaltyPointsWishedToPay != nil {
// if a loyaltyPointsWishedToPay is passed evaluate it within min and max and update loyaltyAmountToSpent:
wishedPrice := loyaltyPointsWishedToPay.GetByType(chargeType)
//nolint:nestif // to be refactored some other day
if loyaltyPointsWishedToPay != nil {
// if a loyaltyPointsWishedToPay is passed evaluate it within min and max and update loyaltyAmountToSpent:
wishedPrice := loyaltyPointsWishedToPay.GetByType(p.ActiveLoyaltyPrice.Type)

if wishedPrice != nil && wishedPrice.Currency() == loyaltyPrice.GetFinalPrice().Currency() {
wishedPriceRounded := wishedPrice.GetPayable()
if wishedPrice != nil && wishedPrice.Currency() == p.ActiveLoyaltyPrice.GetFinalPrice().Currency() {
wishedPriceRounded := wishedPrice.GetPayable()

// if wish is bigger than min we using the wish
if loyaltyAmountToSpent.Cmp(wishedPriceRounded.Amount()) <= 0 {
loyaltyAmountToSpent = *wishedPriceRounded.Amount()
}
// evaluate max
max := loyaltyPrice.getMax(qty)
if max != nil {
// more then max - return max
if max.Cmp(wishedPrice.Amount()) == -1 {
loyaltyAmountToSpent = *max
}
// if wish is bigger than min we using the wish
if loyaltyAmountToSpent.Cmp(wishedPriceRounded.Amount()) <= 0 {
loyaltyAmountToSpent = *wishedPriceRounded.Amount()
}
// evaluate max
max := p.ActiveLoyaltyPrice.getMax(qty)
if max != nil {
// more then max - return max
if max.Cmp(wishedPrice.Amount()) == -1 {
loyaltyAmountToSpent = *max
}
}
}
}

loyaltyCharge := getValidLoyaltyCharge(loyaltyAmountToSpent, loyaltyPrice, chargeType, remainingMainChargeValue)

if !loyaltyCharge.Value.IsPositive() {
continue
}
loyaltyCharge := getValidLoyaltyCharge(loyaltyAmountToSpent, *p.ActiveLoyaltyPrice, *remainingMainChargeValue, p.ActivePrice, *valuedPriceToPay)

// Add the loyalty charge and at the same time reduce the remainingValue
remainingMainChargeValue = new(big.Float).Sub(remainingMainChargeValue, loyaltyCharge.Value.Amount())
requiredCharges[chargeType] = loyaltyCharge
if !loyaltyCharge.Value.IsPositive() {
return buildCharges(requiredCharges, *remainingMainChargeValue, *valuedPriceToPay)
}

remainingMainChargePrice := priceDomain.NewFromBigFloat(*remainingMainChargeValue, valuedPriceToPay.Currency()).GetPayable()
// Add the loyalty charge and at the same time reduce the remainingValue
remainingMainChargeValue = new(big.Float).Sub(remainingMainChargeValue, loyaltyCharge.Value.Amount())
requiredCharges[p.ActiveLoyaltyPrice.Type] = loyaltyCharge

return buildCharges(requiredCharges, *remainingMainChargeValue, *valuedPriceToPay)
}

func buildCharges(requiredCharges map[string]priceDomain.Charge, remainingMainChargeValue big.Float, valuedPriceToPay priceDomain.Price) priceDomain.Charges {
remainingMainChargePrice := priceDomain.NewFromBigFloat(remainingMainChargeValue, valuedPriceToPay.Currency()).GetPayable()

requiredCharges[priceDomain.ChargeTypeMain] = priceDomain.Charge{
Price: remainingMainChargePrice,
Type: priceDomain.ChargeTypeMain,
Value: remainingMainChargePrice,
}

return *priceDomain.NewCharges(requiredCharges)
}

// getValidLoyaltyCharge returns the loyaltyCharge of the given type, making sure the currentlyRemainingMainChargeValue is not exceeded
func getValidLoyaltyCharge(loyaltyAmountWishedToSpent big.Float, activeLoyaltyPrice LoyaltyPriceInfo, currentlyRemainingMainChargeValue big.Float, activePrice PriceInfo, valuedPriceToPay priceDomain.Price) priceDomain.Charge {
loyaltyCurrency := activeLoyaltyPrice.GetFinalPrice().Currency()
rateLoyaltyFinalPriceToRealFinalPrice := activeLoyaltyPrice.GetRate(activePrice.GetFinalPrice())
maximumPossibleLoyaltyValue := big.NewFloat(0.0)

if currentlyRemainingMainChargeValue.Cmp(big.NewFloat(0.0)) != 0 {
remainingPrice := priceDomain.NewFromBigFloat(currentlyRemainingMainChargeValue, "").GetPayableByRoundingMode(priceDomain.RoundingModeHalfUp, 1)
maximumPossibleLoyaltyValue = new(big.Float).Quo(remainingPrice.Amount(), &rateLoyaltyFinalPriceToRealFinalPrice)
maximumPossibleLoyaltyValue = priceDomain.NewFromBigFloat(*maximumPossibleLoyaltyValue, "").GetPayableByRoundingMode(priceDomain.RoundingModeHalfUp, 1).Amount()
}

maximumPossibleLoyaltyPrice := priceDomain.NewFromBigFloat(*maximumPossibleLoyaltyValue, loyaltyCurrency).GetPayable()

if loyaltyAmountWishedToSpent.Cmp(maximumPossibleLoyaltyValue) > 0 {
loyaltyAmountWishedToSpent = *maximumPossibleLoyaltyValue
}

valuedLoyalityPrice := priceDomain.NewFromBigFloat(*new(big.Float).Mul(&rateLoyaltyFinalPriceToRealFinalPrice, &loyaltyAmountWishedToSpent), valuedPriceToPay.Currency()).GetPayable()
if maximumPossibleLoyaltyPrice.Amount().Cmp(&loyaltyAmountWishedToSpent) == 0 {
// If the wish equals the rounded maximum - we need to use the complete remaining value
valuedLoyalityPrice = priceDomain.NewFromBigFloat(currentlyRemainingMainChargeValue, valuedPriceToPay.Currency())
}

return priceDomain.Charge{
Price: priceDomain.NewFromBigFloat(loyaltyAmountWishedToSpent, loyaltyCurrency).GetPayable(),
Type: activeLoyaltyPrice.Type,
Value: valuedLoyalityPrice,
}
}

// GetLoyaltyChargeSplit gets the Charges that need to be paid by type:
// Type "main" is the remaining charge in the main currency and the other charges returned are the loyalty price charges that need to be paid.
// The method takes the min, max and the calculated loyalty conversion rate into account
Expand Down Expand Up @@ -633,7 +644,10 @@ func (l LoyaltyPriceInfo) GetRate(valuedPrice priceDomain.Price) big.Float {
if !l.GetFinalPrice().IsPositive() {
return *big.NewFloat(0)
}
return *new(big.Float).Quo(valuedPrice.Amount(), l.GetFinalPrice().Amount())

valuedPriceAsLoyalty := valuedPrice.GetPayableByRoundingMode(priceDomain.RoundingModeHalfUp, 1)

return *new(big.Float).Quo(valuedPriceAsLoyalty.Amount(), l.GetFinalPrice().Amount())
}

// HasMax checks if product has a maximum (points to spend) restriction
Expand Down
Loading

0 comments on commit 86c633f

Please sign in to comment.