Payment Flow + Access Flow Change
This commit is contained in:
@@ -0,0 +1,421 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cloud.o-forge.io/core/oc-lib/dbs"
|
||||
"cloud.o-forge.io/core/oc-lib/models/billing/subscription"
|
||||
"cloud.o-forge.io/core/oc-lib/models/common/enum"
|
||||
"cloud.o-forge.io/core/oc-lib/models/common/pricing"
|
||||
"cloud.o-forge.io/core/oc-lib/models/order"
|
||||
"cloud.o-forge.io/core/oc-lib/models/peer"
|
||||
"cloud.o-forge.io/core/oc-lib/models/resources"
|
||||
"cloud.o-forge.io/core/oc-lib/models/resources/purchase_resource"
|
||||
"cloud.o-forge.io/core/oc-lib/models/utils"
|
||||
"cloud.o-forge.io/core/oc-lib/tools"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Bill struct {
|
||||
utils.AbstractObject
|
||||
OrderID string `json:"order_id" bson:"order_id" validate:"required"`
|
||||
Status enum.CompletionStatus `json:"status" bson:"status" default:"0"`
|
||||
SubOrders map[string]*PeerOrder `json:"sub_orders" bson:"sub_orders"`
|
||||
Total float64 `json:"total" bson:"total" validate:"required"`
|
||||
}
|
||||
|
||||
func (ri *Bill) Extend(typ ...string) map[string][]tools.DataType {
|
||||
ext := ri.AbstractObject.Extend(typ...)
|
||||
for _, t := range typ {
|
||||
switch t {
|
||||
case "order":
|
||||
if _, ok := ext[t]; !ok {
|
||||
ext[t] = []tools.DataType{}
|
||||
}
|
||||
ext[t] = append(ext[t], tools.ORDER)
|
||||
}
|
||||
}
|
||||
return ext
|
||||
}
|
||||
|
||||
// IsFullySettled retourne vrai quand chaque ligne de chaque peer-order est réglée.
|
||||
func (b *Bill) IsFullySettled() bool {
|
||||
for _, po := range b.SubOrders {
|
||||
for _, item := range po.Items {
|
||||
if !item.Settled {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// SettledTotal retourne le montant total des lignes déjà réglées.
|
||||
func (b *Bill) SettledTotal() float64 {
|
||||
total := 0.0
|
||||
for _, po := range b.SubOrders {
|
||||
for _, item := range po.Items {
|
||||
if item.Settled {
|
||||
total += item.UnitPriceHT * float64(item.Quantity)
|
||||
}
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// MarkItemSettled marque une ligne comme réglée d'après son itemID
|
||||
// et propage le statut PAID sur le PeerOrder si toutes ses lignes sont réglées.
|
||||
func (b *Bill) MarkItemSettled(itemID string) bool {
|
||||
now := time.Now().UTC()
|
||||
for _, po := range b.SubOrders {
|
||||
for _, item := range po.Items {
|
||||
if item.ItemID == itemID {
|
||||
item.Settled = true
|
||||
item.SettledAt = &now
|
||||
// propage le statut PAID si toutes les lignes du peer sont réglées
|
||||
if po.IsFullySettled() {
|
||||
po.Status = enum.PAID
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func GenerateBill(ord *order.Order, request *tools.APIRequest) (*Bill, error) {
|
||||
return &Bill{
|
||||
AbstractObject: utils.AbstractObject{
|
||||
Name: "bill_" + request.PeerID + "_" + time.Now().UTC().Format("2006-01-02T15:04:05"),
|
||||
IsDraft: false,
|
||||
},
|
||||
OrderID: ord.UUID,
|
||||
Status: enum.PENDING,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DraftFirstBill crée le premier brouillon de facture pour un order.
|
||||
// Règle :
|
||||
// - Calcul total indépendant du mode de paiement (photo du coût réel).
|
||||
// - Les purchases sont toujours BILL_ONCE / PAY_ONCE → réglées immédiatement.
|
||||
// - Les bookings avec BillingStrategy != BILL_ONCE génèrent des Subscription.
|
||||
// - Chaque ligne reçoit un ItemID unique pour le suivi de règlement.
|
||||
func DraftFirstBill(ord *order.Order, request *tools.APIRequest) (*Bill, error) {
|
||||
peers := map[string][]*PeerItemOrder{}
|
||||
|
||||
// Purchases : facturation immédiate, pas de subscription
|
||||
for _, p := range ord.Purchases {
|
||||
if _, ok := peers[p.DestPeerID]; !ok {
|
||||
peers[p.DestPeerID] = []*PeerItemOrder{}
|
||||
}
|
||||
peers[p.DestPeerID] = append(peers[p.DestPeerID], &PeerItemOrder{
|
||||
ItemID: uuid.New().String(),
|
||||
ResourceType: p.ResourceType,
|
||||
Purchase: p,
|
||||
Item: p.PricedItem,
|
||||
Quantity: 1,
|
||||
BillingStrategy: pricing.BILL_ONCE,
|
||||
PaymentType: pricing.PAY_ONCE,
|
||||
})
|
||||
}
|
||||
|
||||
// Bookings : exclure les ressources déjà achetées (purchase_resource existant)
|
||||
purchasedIDs := map[string]bool{}
|
||||
for _, p := range ord.Purchases {
|
||||
purchasedIDs[p.ResourceID] = true
|
||||
}
|
||||
for _, b := range ord.Bookings {
|
||||
if purchasedIDs[b.ResourceID] {
|
||||
continue
|
||||
}
|
||||
if _, ok := peers[b.DestPeerID]; !ok {
|
||||
peers[b.DestPeerID] = []*PeerItemOrder{}
|
||||
}
|
||||
peers[b.DestPeerID] = append(peers[b.DestPeerID], &PeerItemOrder{
|
||||
ItemID: uuid.New().String(),
|
||||
ResourceType: b.ResourceType,
|
||||
Quantity: 1,
|
||||
Item: b.PricedItem,
|
||||
BillingStrategy: b.BillingStrategy,
|
||||
PaymentType: b.PaymentType,
|
||||
})
|
||||
}
|
||||
|
||||
// Résolution des adresses de facturation peer
|
||||
peerOrders := map[string]*PeerOrder{}
|
||||
for peerID, items := range peers {
|
||||
pr, _, err := peer.NewAccessor(request).LoadOne(peerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
peerOrders[peerID] = &PeerOrder{
|
||||
PeerID: peerID,
|
||||
BillingAddress: pr.(*peer.Peer).WalletAddress,
|
||||
Items: items,
|
||||
}
|
||||
}
|
||||
|
||||
bill := &Bill{
|
||||
AbstractObject: utils.AbstractObject{
|
||||
Name: "bill_" + request.PeerID + "_" + time.Now().UTC().Format("2006-01-02T15:04:05"),
|
||||
IsDraft: true,
|
||||
},
|
||||
OrderID: ord.UUID,
|
||||
Status: enum.PENDING,
|
||||
SubOrders: peerOrders,
|
||||
}
|
||||
|
||||
// 1. Calcul des totaux (indépendant du mode de paiement)
|
||||
if _, err := bill.SumUpBill(request); err != nil {
|
||||
return bill, err
|
||||
}
|
||||
|
||||
// 2. Création des subscriptions pour les lignes récurrentes
|
||||
subIDs, err := createRecurringSubscriptions(bill, request)
|
||||
if err != nil {
|
||||
return bill, err
|
||||
}
|
||||
|
||||
// 3. Liaison des subscription IDs à l'order pour traçabilité
|
||||
if len(subIDs) > 0 {
|
||||
ord.SubscriptionIDs = append(ord.SubscriptionIDs, subIDs...)
|
||||
}
|
||||
|
||||
// 4. Persistance du brouillon de facture (les UnitPriceHT et SubscriptionID sont déjà set)
|
||||
stored, _, err := NewAccessor(request).StoreOne(bill)
|
||||
if err != nil {
|
||||
return bill, err
|
||||
}
|
||||
return stored.(*Bill), nil
|
||||
}
|
||||
|
||||
// createRecurringSubscriptions crée les Subscription pour chaque groupe
|
||||
// (peer × BillingStrategy) dont la stratégie est récurrente.
|
||||
// Modifie les PeerItemOrder en place (SubscriptionID).
|
||||
// Retourne les IDs de subscriptions créées.
|
||||
func createRecurringSubscriptions(b *Bill, request *tools.APIRequest) ([]string, error) {
|
||||
subIDs := []string{}
|
||||
|
||||
for peerID, po := range b.SubOrders {
|
||||
// Groupe les items récurrents par BillingStrategy
|
||||
byStrategy := map[pricing.BillingStrategy][]*PeerItemOrder{}
|
||||
for _, item := range po.Items {
|
||||
if item.BillingStrategy == pricing.BILL_ONCE {
|
||||
continue
|
||||
}
|
||||
byStrategy[item.BillingStrategy] = append(byStrategy[item.BillingStrategy], item)
|
||||
}
|
||||
|
||||
for strategy, items := range byStrategy {
|
||||
subItems := make([]*subscription.SubscriptionItem, 0, len(items))
|
||||
totalAmount := 0.0
|
||||
for _, item := range items {
|
||||
subItems = append(subItems, &subscription.SubscriptionItem{
|
||||
ResourceType: item.ResourceType,
|
||||
Quantity: item.Quantity,
|
||||
UnitPrice: item.UnitPriceHT,
|
||||
})
|
||||
totalAmount += item.UnitPriceHT * float64(item.Quantity)
|
||||
}
|
||||
|
||||
var sub *subscription.Subscription
|
||||
switch strategy {
|
||||
case pricing.BILL_PER_YEAR:
|
||||
sub = subscription.NewYearlySubscription(request.PeerID, peerID, subItems, totalAmount, "EUR")
|
||||
case pricing.BILL_PER_WEEK:
|
||||
sub = subscription.NewWeeklySubscription(request.PeerID, peerID, subItems, totalAmount, "EUR")
|
||||
default: // BILL_PER_MONTH et tout autre cas récurrent
|
||||
sub = subscription.NewMonthlySubscription(request.PeerID, peerID, subItems, totalAmount, "EUR")
|
||||
}
|
||||
sub.IsDraft = true
|
||||
|
||||
res, _, err := subscription.NewAccessor(request).StoreOne(sub)
|
||||
if err != nil {
|
||||
return subIDs, err
|
||||
}
|
||||
storedSub := res.(*subscription.Subscription)
|
||||
subIDs = append(subIDs, storedSub.GetID())
|
||||
|
||||
// Lie le SubscriptionID à chaque ligne concernée
|
||||
for _, item := range items {
|
||||
item.SubscriptionID = storedSub.GetID()
|
||||
}
|
||||
}
|
||||
}
|
||||
return subIDs, nil
|
||||
}
|
||||
|
||||
func (d *Bill) GetAccessor(request *tools.APIRequest) utils.Accessor {
|
||||
return NewAccessor(request)
|
||||
}
|
||||
|
||||
func (r *Bill) StoreDraftDefault() {
|
||||
r.IsDraft = true
|
||||
}
|
||||
|
||||
func (r *Bill) CanUpdate(set utils.DBObject) (bool, utils.DBObject) {
|
||||
if !r.IsDraft && r.Status != set.(*Bill).Status {
|
||||
return true, &Bill{Status: set.(*Bill).Status}
|
||||
}
|
||||
return r.IsDraft, set
|
||||
}
|
||||
|
||||
func (r *Bill) CanDelete() bool {
|
||||
return r.IsDraft
|
||||
}
|
||||
|
||||
func (d *Bill) SumUpBill(request *tools.APIRequest) (*Bill, error) {
|
||||
for _, b := range d.SubOrders {
|
||||
err := b.SumUpBill(request)
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
d.Total += b.Total
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PeerOrder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type PeerOrder struct {
|
||||
Error string `json:"error,omitempty" bson:"error,omitempty"`
|
||||
PeerID string `json:"peer_id,omitempty" bson:"peer_id,omitempty"`
|
||||
Status enum.CompletionStatus `json:"status" bson:"status" default:"0"`
|
||||
BillingAddress string `json:"billing_address,omitempty" bson:"billing_address,omitempty"`
|
||||
Items []*PeerItemOrder `json:"items,omitempty" bson:"items,omitempty"`
|
||||
Total float64 `json:"total,omitempty" bson:"total,omitempty"`
|
||||
}
|
||||
|
||||
// IsFullySettled retourne vrai si toutes les lignes de ce peer sont réglées.
|
||||
func (po *PeerOrder) IsFullySettled() bool {
|
||||
for _, item := range po.Items {
|
||||
if !item.Settled {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func PricedByType(dt tools.DataType) pricing.PricedItemITF {
|
||||
switch dt {
|
||||
case tools.PROCESSING_RESOURCE:
|
||||
return &resources.PricedProcessingResource{}
|
||||
case tools.STORAGE_RESOURCE:
|
||||
return &resources.PricedStorageResource{}
|
||||
case tools.DATA_RESOURCE:
|
||||
return &resources.PricedDataResource{}
|
||||
case tools.COMPUTE_RESOURCE:
|
||||
return &resources.PricedComputeResource{}
|
||||
case tools.WORKFLOW_RESOURCE:
|
||||
return &resources.PricedResource[*pricing.ExploitPricingProfile[pricing.TimePricingStrategy]]{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *PeerOrder) Pay(request *tools.APIRequest, response chan *PeerOrder, wg *sync.WaitGroup) {
|
||||
d.Status = enum.PENDING
|
||||
go func() {
|
||||
// DO SOMETHING TO PAY ON BLOCKCHAIN OR WHATEVER — UPDATE STATUS ON RETURN
|
||||
d.Status = enum.PAID // TO REMOVE LATER IT'S A MOCK
|
||||
if d.Status == enum.PAID {
|
||||
now := time.Now().UTC()
|
||||
for _, b := range d.Items {
|
||||
priced := PricedByType(b.ResourceType)
|
||||
bb, _ := json.Marshal(b.Item)
|
||||
json.Unmarshal(bb, priced)
|
||||
if !priced.IsPurchasable() {
|
||||
continue
|
||||
}
|
||||
accessor := purchase_resource.NewAccessor(request)
|
||||
accessor.StoreOne(&purchase_resource.PurchaseResource{
|
||||
ResourceID: priced.GetID(),
|
||||
ResourceType: priced.GetType(),
|
||||
EndDate: priced.GetLocationEnd(),
|
||||
})
|
||||
// Marque la ligne comme réglée
|
||||
b.Settled = true
|
||||
b.SettledAt = &now
|
||||
}
|
||||
}
|
||||
if d.Status != enum.PENDING {
|
||||
response <- d
|
||||
}
|
||||
wg.Done()
|
||||
}()
|
||||
}
|
||||
|
||||
func (d *PeerOrder) SumUpBill(request *tools.APIRequest) error {
|
||||
for _, b := range d.Items {
|
||||
tot, err := b.GetPriceHT(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.Total += tot
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PeerItemOrder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// PeerItemOrder est une ligne de facture pour un peer donné.
|
||||
type PeerItemOrder struct {
|
||||
// ItemID identifie de manière unique cette ligne pour le suivi de règlement.
|
||||
ItemID string `json:"item_id,omitempty" bson:"item_id,omitempty"`
|
||||
ResourceType tools.DataType `json:"datatype,omitempty" bson:"datatype,omitempty"`
|
||||
Quantity int `json:"quantity,omitempty" bson:"quantity,omitempty"`
|
||||
Purchase *purchase_resource.PurchaseResource `json:"purchase,omitempty" bson:"purchase,omitempty"`
|
||||
Item map[string]interface{} `json:"item,omitempty" bson:"item,omitempty"`
|
||||
|
||||
BillingStrategy pricing.BillingStrategy `json:"billing_strategy" bson:"billing_strategy"`
|
||||
PaymentType pricing.PaymentType `json:"payment_type" bson:"payment_type"`
|
||||
|
||||
// UnitPriceHT est le prix unitaire HT calculé par SumUpBill/GetPriceHT.
|
||||
// Utilisé pour la création des subscriptions sans recalcul.
|
||||
UnitPriceHT float64 `json:"unit_price_ht,omitempty" bson:"unit_price_ht,omitempty"`
|
||||
|
||||
// SubscriptionID référence la Subscription créée pour les lignes récurrentes.
|
||||
// Vide pour les lignes BILL_ONCE.
|
||||
SubscriptionID string `json:"subscription_id,omitempty" bson:"subscription_id,omitempty"`
|
||||
|
||||
// Settled indique si cette ligne a été réglée (paiement confirmé).
|
||||
Settled bool `json:"settled" bson:"settled"`
|
||||
SettledAt *time.Time `json:"settled_at,omitempty" bson:"settled_at,omitempty"`
|
||||
}
|
||||
|
||||
func (d *PeerItemOrder) GetPriceHT(request *tools.APIRequest) (float64, error) {
|
||||
if d.Purchase == nil {
|
||||
return 0, nil
|
||||
}
|
||||
priced := PricedByType(d.ResourceType)
|
||||
b, _ := json.Marshal(d.Item)
|
||||
err := json.Unmarshal(b, priced)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
accessor := purchase_resource.NewAccessor(request)
|
||||
search, code, _ := accessor.Search(&dbs.Filters{
|
||||
And: map[string][]dbs.Filter{
|
||||
"resource_id": {{Operator: dbs.EQUAL.String(), Value: priced.GetID()}},
|
||||
},
|
||||
}, "", d.Purchase.IsDraft, 0, 10000)
|
||||
if code == 200 && len(search) > 0 {
|
||||
for _, s := range search {
|
||||
if s.(*purchase_resource.PurchaseResource).EndDate == nil ||
|
||||
time.Now().UTC().After(*s.(*purchase_resource.PurchaseResource).EndDate) {
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
unitPrice, err := priced.GetPriceHT()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
d.UnitPriceHT = unitPrice // cache pour createRecurringSubscriptions
|
||||
return unitPrice * float64(d.Quantity), nil
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"cloud.o-forge.io/core/oc-lib/logs"
|
||||
"cloud.o-forge.io/core/oc-lib/models/utils"
|
||||
"cloud.o-forge.io/core/oc-lib/tools"
|
||||
)
|
||||
|
||||
type billMongoAccessor struct {
|
||||
utils.AbstractAccessor[*Bill]
|
||||
}
|
||||
|
||||
func NewAccessor(request *tools.APIRequest) *billMongoAccessor {
|
||||
return &billMongoAccessor{
|
||||
AbstractAccessor: utils.AbstractAccessor[*Bill]{
|
||||
Logger: logs.CreateLogger(tools.BILL.String()),
|
||||
Request: request,
|
||||
Type: tools.BILL,
|
||||
New: func() *Bill { return &Bill{} },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package discount
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"cloud.o-forge.io/core/oc-lib/models/utils"
|
||||
"cloud.o-forge.io/core/oc-lib/tools"
|
||||
)
|
||||
|
||||
type DiscountType int
|
||||
|
||||
const (
|
||||
PERCENTAGE DiscountType = iota // réduction en pourcentage
|
||||
FIXED_AMOUNT // réduction montant fixe
|
||||
)
|
||||
|
||||
func (d DiscountType) String() string {
|
||||
return [...]string{"percentage", "fixed_amount"}[d]
|
||||
}
|
||||
|
||||
func DiscountTypeList() []DiscountType {
|
||||
return []DiscountType{PERCENTAGE, FIXED_AMOUNT}
|
||||
}
|
||||
|
||||
type DiscountScope int
|
||||
|
||||
const (
|
||||
SCOPE_ALL DiscountScope = iota // applicable à tout
|
||||
SCOPE_RESOURCE_TYPE // applicable à un type de ressource
|
||||
SCOPE_RESOURCE // applicable à une ressource spécifique
|
||||
SCOPE_SUBSCRIPTION // applicable aux souscriptions
|
||||
)
|
||||
|
||||
func (d DiscountScope) String() string {
|
||||
return [...]string{"all", "resource_type", "resource", "subscription"}[d]
|
||||
}
|
||||
|
||||
// Discount représente une réduction applicable sur les ressources ou abonnements.
|
||||
type Discount struct {
|
||||
utils.AbstractObject
|
||||
Code string `json:"code,omitempty" bson:"code,omitempty"`
|
||||
DiscountType DiscountType `json:"discount_type" bson:"discount_type"`
|
||||
Scope DiscountScope `json:"scope" bson:"scope"`
|
||||
Value float64 `json:"value" bson:"value"` // pourcentage (0-100) ou montant fixe
|
||||
Currency string `json:"currency,omitempty" bson:"currency,omitempty"` // pour FIXED_AMOUNT
|
||||
ResourceTypes []tools.DataType `json:"resource_types,omitempty" bson:"resource_types,omitempty"` // si SCOPE_RESOURCE_TYPE
|
||||
ResourceIDs []string `json:"resource_ids,omitempty" bson:"resource_ids,omitempty"` // si SCOPE_RESOURCE
|
||||
ValidFrom *time.Time `json:"valid_from,omitempty" bson:"valid_from,omitempty"`
|
||||
ValidUntil *time.Time `json:"valid_until,omitempty" bson:"valid_until,omitempty"`
|
||||
MaxUsage int `json:"max_usage,omitempty" bson:"max_usage,omitempty"` // 0 = illimité
|
||||
CurrentUsage int `json:"current_usage" bson:"current_usage"`
|
||||
MinAmount float64 `json:"min_amount,omitempty" bson:"min_amount,omitempty"` // montant minimum du bill pour appliquer
|
||||
Active bool `json:"active" bson:"active" default:"true"`
|
||||
}
|
||||
|
||||
// IsValid vérifie si la réduction est applicable au moment présent.
|
||||
func (d *Discount) IsValid(billAmount float64) bool {
|
||||
now := time.Now().UTC()
|
||||
if !d.Active {
|
||||
return false
|
||||
}
|
||||
if d.MaxUsage > 0 && d.CurrentUsage >= d.MaxUsage {
|
||||
return false
|
||||
}
|
||||
if d.ValidFrom != nil && now.Before(*d.ValidFrom) {
|
||||
return false
|
||||
}
|
||||
if d.ValidUntil != nil && now.After(*d.ValidUntil) {
|
||||
return false
|
||||
}
|
||||
if d.MinAmount > 0 && billAmount < d.MinAmount {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Apply applique la réduction sur un prix HT et retourne le prix réduit.
|
||||
func (d *Discount) Apply(priceHT float64) float64 {
|
||||
switch d.DiscountType {
|
||||
case PERCENTAGE:
|
||||
return priceHT - (priceHT * d.Value / 100)
|
||||
case FIXED_AMOUNT:
|
||||
result := priceHT - d.Value
|
||||
if result < 0 {
|
||||
return 0
|
||||
}
|
||||
return result
|
||||
}
|
||||
return priceHT
|
||||
}
|
||||
|
||||
// DiscountAmount retourne le montant de la réduction sans l'appliquer.
|
||||
func (d *Discount) DiscountAmount(priceHT float64) float64 {
|
||||
switch d.DiscountType {
|
||||
case PERCENTAGE:
|
||||
return priceHT * d.Value / 100
|
||||
case FIXED_AMOUNT:
|
||||
if d.Value > priceHT {
|
||||
return priceHT
|
||||
}
|
||||
return d.Value
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// AppliesToResource vérifie si cette réduction s'applique à une ressource donnée.
|
||||
func (d *Discount) AppliesToResource(resourceID string, resourceType tools.DataType) bool {
|
||||
switch d.Scope {
|
||||
case SCOPE_ALL:
|
||||
return true
|
||||
case SCOPE_RESOURCE:
|
||||
for _, id := range d.ResourceIDs {
|
||||
if id == resourceID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case SCOPE_RESOURCE_TYPE:
|
||||
for _, t := range d.ResourceTypes {
|
||||
if t == resourceType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IncrementUsage incrémente le compteur d'utilisation.
|
||||
func (d *Discount) IncrementUsage() {
|
||||
d.CurrentUsage++
|
||||
}
|
||||
|
||||
func (d *Discount) GetAccessor(request *tools.APIRequest) utils.Accessor {
|
||||
return NewAccessor(request)
|
||||
}
|
||||
|
||||
func (d *Discount) StoreDraftDefault() {
|
||||
d.IsDraft = true
|
||||
}
|
||||
|
||||
func (d *Discount) CanUpdate(set utils.DBObject) (bool, utils.DBObject) {
|
||||
return d.IsDraft, set
|
||||
}
|
||||
|
||||
func (d *Discount) CanDelete() bool {
|
||||
return d.IsDraft || d.CurrentUsage == 0
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package discount
|
||||
|
||||
import (
|
||||
"cloud.o-forge.io/core/oc-lib/logs"
|
||||
"cloud.o-forge.io/core/oc-lib/models/utils"
|
||||
"cloud.o-forge.io/core/oc-lib/tools"
|
||||
)
|
||||
|
||||
type discountMongoAccessor struct {
|
||||
utils.AbstractAccessor[*Discount]
|
||||
}
|
||||
|
||||
func NewAccessor(request *tools.APIRequest) *discountMongoAccessor {
|
||||
return &discountMongoAccessor{
|
||||
AbstractAccessor: utils.AbstractAccessor[*Discount]{
|
||||
Logger: logs.CreateLogger(tools.DISCOUNT.String()),
|
||||
Request: request,
|
||||
Type: tools.DISCOUNT,
|
||||
New: func() *Discount { return &Discount{} },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"cloud.o-forge.io/core/oc-lib/models/common/pricing"
|
||||
"cloud.o-forge.io/core/oc-lib/models/utils"
|
||||
"cloud.o-forge.io/core/oc-lib/tools"
|
||||
)
|
||||
|
||||
type PaymentStatus int
|
||||
|
||||
const (
|
||||
PAYMENT_PENDING PaymentStatus = iota
|
||||
PAYMENT_PROCESSING // en cours de traitement blockchain/réseau
|
||||
PAYMENT_COMPLETED // confirmé
|
||||
PAYMENT_FAILED // échoué
|
||||
PAYMENT_CANCELLED // annulé avant exécution
|
||||
PAYMENT_REFUNDED // remboursé
|
||||
)
|
||||
|
||||
func (s PaymentStatus) String() string {
|
||||
return [...]string{"pending", "processing", "completed", "failed", "cancelled", "refunded"}[s]
|
||||
}
|
||||
|
||||
func PaymentStatusList() []PaymentStatus {
|
||||
return []PaymentStatus{PAYMENT_PENDING, PAYMENT_PROCESSING, PAYMENT_COMPLETED, PAYMENT_FAILED, PAYMENT_CANCELLED, PAYMENT_REFUNDED}
|
||||
}
|
||||
|
||||
type PaymentMethod int
|
||||
|
||||
const (
|
||||
METHOD_BLOCKCHAIN PaymentMethod = iota
|
||||
METHOD_CREDIT_CARD
|
||||
METHOD_BANK_TRANSFER
|
||||
METHOD_CRYPTO
|
||||
METHOD_INTERNAL_CREDIT // crédit interne à la plateforme
|
||||
)
|
||||
|
||||
func (m PaymentMethod) String() string {
|
||||
return [...]string{"blockchain", "credit_card", "bank_transfer", "crypto", "internal_credit"}[m]
|
||||
}
|
||||
|
||||
func PaymentMethodList() []PaymentMethod {
|
||||
return []PaymentMethod{METHOD_BLOCKCHAIN, METHOD_CREDIT_CARD, METHOD_BANK_TRANSFER, METHOD_CRYPTO, METHOD_INTERNAL_CREDIT}
|
||||
}
|
||||
|
||||
// Payment représente une transaction de paiement — instantanée, mensuelle ou annuelle.
|
||||
type Payment struct {
|
||||
utils.AbstractObject
|
||||
BillID string `json:"bill_id,omitempty" bson:"bill_id,omitempty"`
|
||||
InvoiceID string `json:"invoice_id,omitempty" bson:"invoice_id,omitempty"`
|
||||
SubscriptionID string `json:"subscription_id,omitempty" bson:"subscription_id,omitempty"`
|
||||
PayerPeerID string `json:"payer_peer_id,omitempty" bson:"payer_peer_id,omitempty"`
|
||||
RecipientPeerID string `json:"recipient_peer_id,omitempty" bson:"recipient_peer_id,omitempty"`
|
||||
Amount float64 `json:"amount" bson:"amount"`
|
||||
Currency string `json:"currency" bson:"currency" default:"EUR"`
|
||||
Status PaymentStatus `json:"status" bson:"status"`
|
||||
Method PaymentMethod `json:"method" bson:"method"`
|
||||
PaymentType pricing.PaymentType `json:"payment_type" bson:"payment_type"` // PAY_ONCE, PAY_EVERY_MONTH, PAY_EVERY_YEAR
|
||||
TransactionID string `json:"transaction_id,omitempty" bson:"transaction_id,omitempty"`
|
||||
WalletFrom string `json:"wallet_from,omitempty" bson:"wallet_from,omitempty"`
|
||||
WalletTo string `json:"wallet_to,omitempty" bson:"wallet_to,omitempty"`
|
||||
ScheduledAt *time.Time `json:"scheduled_at,omitempty" bson:"scheduled_at,omitempty"`
|
||||
ProcessedAt *time.Time `json:"processed_at,omitempty" bson:"processed_at,omitempty"`
|
||||
FailureReason string `json:"failure_reason,omitempty" bson:"failure_reason,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty" bson:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// NewInstantPayment crée un paiement immédiat (PAY_ONCE).
|
||||
func NewInstantPayment(billID, payerPeerID, recipientPeerID string, amount float64, currency string, method PaymentMethod) *Payment {
|
||||
return &Payment{
|
||||
BillID: billID,
|
||||
PayerPeerID: payerPeerID,
|
||||
RecipientPeerID: recipientPeerID,
|
||||
Amount: amount,
|
||||
Currency: currency,
|
||||
Status: PAYMENT_PENDING,
|
||||
Method: method,
|
||||
PaymentType: pricing.PAY_ONCE,
|
||||
}
|
||||
}
|
||||
|
||||
// NewScheduledPayment crée un paiement programmé (mensuel ou annuel).
|
||||
func NewScheduledPayment(subscriptionID, payerPeerID, recipientPeerID string, amount float64, currency string, method PaymentMethod, paymentType pricing.PaymentType, scheduledAt time.Time) *Payment {
|
||||
return &Payment{
|
||||
SubscriptionID: subscriptionID,
|
||||
PayerPeerID: payerPeerID,
|
||||
RecipientPeerID: recipientPeerID,
|
||||
Amount: amount,
|
||||
Currency: currency,
|
||||
Status: PAYMENT_PENDING,
|
||||
Method: method,
|
||||
PaymentType: paymentType,
|
||||
ScheduledAt: &scheduledAt,
|
||||
}
|
||||
}
|
||||
|
||||
// Complete marque le paiement comme confirmé.
|
||||
func (p *Payment) Complete(transactionID string) {
|
||||
now := time.Now().UTC()
|
||||
p.Status = PAYMENT_COMPLETED
|
||||
p.TransactionID = transactionID
|
||||
p.ProcessedAt = &now
|
||||
}
|
||||
|
||||
// Fail marque le paiement comme échoué.
|
||||
func (p *Payment) Fail(reason string) {
|
||||
now := time.Now().UTC()
|
||||
p.Status = PAYMENT_FAILED
|
||||
p.FailureReason = reason
|
||||
p.ProcessedAt = &now
|
||||
}
|
||||
|
||||
// Cancel annule le paiement s'il est encore en attente.
|
||||
func (p *Payment) Cancel() bool {
|
||||
if p.Status != PAYMENT_PENDING {
|
||||
return false
|
||||
}
|
||||
p.Status = PAYMENT_CANCELLED
|
||||
return true
|
||||
}
|
||||
|
||||
// IsRefundable indique si le paiement peut faire l'objet d'un remboursement.
|
||||
func (p *Payment) IsRefundable() bool {
|
||||
return p.Status == PAYMENT_COMPLETED
|
||||
}
|
||||
|
||||
func (p *Payment) GetAccessor(request *tools.APIRequest) utils.Accessor {
|
||||
return NewAccessor(request)
|
||||
}
|
||||
|
||||
func (p *Payment) StoreDraftDefault() {
|
||||
p.IsDraft = true
|
||||
}
|
||||
|
||||
func (p *Payment) CanUpdate(set utils.DBObject) (bool, utils.DBObject) {
|
||||
incoming := set.(*Payment)
|
||||
if !p.IsDraft && p.Status != incoming.Status {
|
||||
return true, &Payment{
|
||||
Status: incoming.Status,
|
||||
TransactionID: incoming.TransactionID,
|
||||
FailureReason: incoming.FailureReason,
|
||||
}
|
||||
}
|
||||
return p.IsDraft, set
|
||||
}
|
||||
|
||||
func (p *Payment) CanDelete() bool {
|
||||
return p.IsDraft || p.Status == PAYMENT_PENDING
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"cloud.o-forge.io/core/oc-lib/logs"
|
||||
"cloud.o-forge.io/core/oc-lib/models/utils"
|
||||
"cloud.o-forge.io/core/oc-lib/tools"
|
||||
)
|
||||
|
||||
type paymentMongoAccessor struct {
|
||||
utils.AbstractAccessor[*Payment]
|
||||
}
|
||||
|
||||
func NewAccessor(request *tools.APIRequest) *paymentMongoAccessor {
|
||||
return &paymentMongoAccessor{
|
||||
AbstractAccessor: utils.AbstractAccessor[*Payment]{
|
||||
Logger: logs.CreateLogger(tools.PAYMENT.String()),
|
||||
Request: request,
|
||||
Type: tools.PAYMENT,
|
||||
New: func() *Payment { return &Payment{} },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"cloud.o-forge.io/core/oc-lib/models/common/pricing"
|
||||
)
|
||||
|
||||
type ScheduleStatus int
|
||||
|
||||
const (
|
||||
SCHEDULE_ACTIVE ScheduleStatus = iota
|
||||
SCHEDULE_PAUSED // mis en pause manuellement
|
||||
SCHEDULE_CANCELLED // résilié
|
||||
SCHEDULE_COMPLETED // terminé normalement (abonnement expiré)
|
||||
SCHEDULE_FAILED // trop d'échecs consécutifs
|
||||
)
|
||||
|
||||
func (s ScheduleStatus) String() string {
|
||||
return [...]string{"active", "paused", "cancelled", "completed", "failed"}[s]
|
||||
}
|
||||
|
||||
// PaymentSchedule pilote la récurrence des paiements d'un abonnement.
|
||||
type PaymentSchedule struct {
|
||||
SubscriptionID string `json:"subscription_id" bson:"subscription_id"`
|
||||
Frequency pricing.PaymentType `json:"frequency" bson:"frequency"` // PAY_EVERY_WEEK / PAY_EVERY_MONTH / PAY_EVERY_YEAR
|
||||
Amount float64 `json:"amount" bson:"amount"`
|
||||
Currency string `json:"currency" bson:"currency"`
|
||||
Status ScheduleStatus `json:"status" bson:"status"`
|
||||
NextPaymentDate time.Time `json:"next_payment_date" bson:"next_payment_date"`
|
||||
LastExecutedAt *time.Time `json:"last_executed_at,omitempty" bson:"last_executed_at,omitempty"`
|
||||
FailureCount int `json:"failure_count" bson:"failure_count"`
|
||||
MaxRetries int `json:"max_retries" bson:"max_retries" default:"3"`
|
||||
}
|
||||
|
||||
// nextDate calcule la prochaine date selon la fréquence.
|
||||
func (ps *PaymentSchedule) nextDate() time.Time {
|
||||
switch ps.Frequency {
|
||||
case pricing.PAY_EVERY_WEEK:
|
||||
return ps.NextPaymentDate.AddDate(0, 0, 7)
|
||||
case pricing.PAY_EVERY_MONTH:
|
||||
return ps.NextPaymentDate.AddDate(0, 1, 0)
|
||||
case pricing.PAY_EVERY_YEAR:
|
||||
return ps.NextPaymentDate.AddDate(1, 0, 0)
|
||||
}
|
||||
return ps.NextPaymentDate
|
||||
}
|
||||
|
||||
// Advance enregistre l'exécution réussie et avance à la prochaine échéance.
|
||||
func (ps *PaymentSchedule) Advance() {
|
||||
now := time.Now().UTC()
|
||||
ps.LastExecutedAt = &now
|
||||
ps.FailureCount = 0
|
||||
ps.NextPaymentDate = ps.nextDate()
|
||||
}
|
||||
|
||||
// RecordFailure incrémente le compteur d'échecs et désactive après MaxRetries.
|
||||
func (ps *PaymentSchedule) RecordFailure() {
|
||||
ps.FailureCount++
|
||||
if ps.MaxRetries > 0 && ps.FailureCount >= ps.MaxRetries {
|
||||
ps.Status = SCHEDULE_FAILED
|
||||
}
|
||||
}
|
||||
|
||||
// IsDue retourne vrai si le paiement est dû maintenant.
|
||||
func (ps *PaymentSchedule) IsDue() bool {
|
||||
return ps.Status == SCHEDULE_ACTIVE && !time.Now().UTC().Before(ps.NextPaymentDate)
|
||||
}
|
||||
|
||||
// Pause suspend temporairement le calendrier.
|
||||
func (ps *PaymentSchedule) Pause() {
|
||||
if ps.Status == SCHEDULE_ACTIVE {
|
||||
ps.Status = SCHEDULE_PAUSED
|
||||
}
|
||||
}
|
||||
|
||||
// Resume réactive un calendrier mis en pause.
|
||||
func (ps *PaymentSchedule) Resume() {
|
||||
if ps.Status == SCHEDULE_PAUSED {
|
||||
ps.Status = SCHEDULE_ACTIVE
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package refund
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"cloud.o-forge.io/core/oc-lib/models/common/pricing"
|
||||
"cloud.o-forge.io/core/oc-lib/models/utils"
|
||||
"cloud.o-forge.io/core/oc-lib/tools"
|
||||
)
|
||||
|
||||
type RefundStatus int
|
||||
|
||||
const (
|
||||
REFUND_PENDING RefundStatus = iota
|
||||
REFUND_APPROVED // approuvé, en attente de traitement
|
||||
REFUND_REJECTED // rejeté
|
||||
REFUND_PROCESSING // en cours de virement/blockchain
|
||||
REFUND_COMPLETED // remboursé
|
||||
REFUND_CANCELLED // annulé avant approbation
|
||||
)
|
||||
|
||||
func (s RefundStatus) String() string {
|
||||
return [...]string{"pending", "approved", "rejected", "processing", "completed", "cancelled"}[s]
|
||||
}
|
||||
|
||||
func RefundStatusList() []RefundStatus {
|
||||
return []RefundStatus{REFUND_PENDING, REFUND_APPROVED, REFUND_REJECTED, REFUND_PROCESSING, REFUND_COMPLETED, REFUND_CANCELLED}
|
||||
}
|
||||
|
||||
// Refund représente une demande de remboursement sur un paiement validé.
|
||||
type Refund struct {
|
||||
utils.AbstractObject
|
||||
PaymentID string `json:"payment_id" bson:"payment_id" validate:"required"`
|
||||
BillID string `json:"bill_id,omitempty" bson:"bill_id,omitempty"`
|
||||
InvoiceID string `json:"invoice_id,omitempty" bson:"invoice_id,omitempty"`
|
||||
RefundType pricing.RefundType `json:"refund_type" bson:"refund_type"`
|
||||
Amount float64 `json:"amount" bson:"amount" validate:"required"`
|
||||
Currency string `json:"currency" bson:"currency" default:"EUR"`
|
||||
Reason string `json:"reason,omitempty" bson:"reason,omitempty"`
|
||||
Status RefundStatus `json:"status" bson:"status"`
|
||||
RequestedAt time.Time `json:"requested_at" bson:"requested_at"`
|
||||
ProcessedAt *time.Time `json:"processed_at,omitempty" bson:"processed_at,omitempty"`
|
||||
ProcessedByID string `json:"processed_by_id,omitempty" bson:"processed_by_id,omitempty"`
|
||||
TransactionID string `json:"transaction_id,omitempty" bson:"transaction_id,omitempty"`
|
||||
Notes string `json:"notes,omitempty" bson:"notes,omitempty"`
|
||||
// ratio appliqué sur le montant original (0-100). 0 = non renseigné (remboursement total).
|
||||
RefundRatio float64 `json:"refund_ratio,omitempty" bson:"refund_ratio,omitempty"`
|
||||
}
|
||||
|
||||
// NewRefund crée une demande de remboursement total.
|
||||
func NewRefund(paymentID, billID string, amount float64, currency string, refundType pricing.RefundType, reason string) *Refund {
|
||||
return &Refund{
|
||||
PaymentID: paymentID,
|
||||
BillID: billID,
|
||||
Amount: amount,
|
||||
Currency: currency,
|
||||
RefundType: refundType,
|
||||
Reason: reason,
|
||||
Status: REFUND_PENDING,
|
||||
RequestedAt: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewPartialRefund crée une demande de remboursement partiel selon un ratio pourcentage.
|
||||
func NewPartialRefund(paymentID, billID string, originalAmount, ratioPercent float64, currency string, refundType pricing.RefundType, reason string) *Refund {
|
||||
amount := originalAmount * ratioPercent / 100
|
||||
r := NewRefund(paymentID, billID, amount, currency, refundType, reason)
|
||||
r.RefundRatio = ratioPercent
|
||||
return r
|
||||
}
|
||||
|
||||
// Approve approuve la demande de remboursement.
|
||||
func (r *Refund) Approve(processedByID string) {
|
||||
r.Status = REFUND_APPROVED
|
||||
r.ProcessedByID = processedByID
|
||||
}
|
||||
|
||||
// Reject rejette la demande de remboursement.
|
||||
func (r *Refund) Reject(processedByID, notes string) {
|
||||
now := time.Now().UTC()
|
||||
r.Status = REFUND_REJECTED
|
||||
r.ProcessedByID = processedByID
|
||||
r.ProcessedAt = &now
|
||||
r.Notes = notes
|
||||
}
|
||||
|
||||
// Process passe le remboursement en cours de traitement.
|
||||
func (r *Refund) Process() bool {
|
||||
if r.Status != REFUND_APPROVED {
|
||||
return false
|
||||
}
|
||||
r.Status = REFUND_PROCESSING
|
||||
return true
|
||||
}
|
||||
|
||||
// Complete finalise le remboursement avec l'identifiant de transaction.
|
||||
func (r *Refund) Complete(transactionID string) {
|
||||
now := time.Now().UTC()
|
||||
r.Status = REFUND_COMPLETED
|
||||
r.TransactionID = transactionID
|
||||
r.ProcessedAt = &now
|
||||
}
|
||||
|
||||
// Cancel annule la demande si elle est encore en attente.
|
||||
func (r *Refund) Cancel() bool {
|
||||
if r.Status != REFUND_PENDING {
|
||||
return false
|
||||
}
|
||||
r.Status = REFUND_CANCELLED
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *Refund) GetAccessor(request *tools.APIRequest) utils.Accessor {
|
||||
return NewAccessor(request)
|
||||
}
|
||||
|
||||
func (r *Refund) StoreDraftDefault() {
|
||||
r.IsDraft = true
|
||||
}
|
||||
|
||||
func (r *Refund) CanUpdate(set utils.DBObject) (bool, utils.DBObject) {
|
||||
incoming := set.(*Refund)
|
||||
if !r.IsDraft && r.Status != incoming.Status {
|
||||
return true, &Refund{
|
||||
Status: incoming.Status,
|
||||
TransactionID: incoming.TransactionID,
|
||||
Notes: incoming.Notes,
|
||||
ProcessedByID: incoming.ProcessedByID,
|
||||
}
|
||||
}
|
||||
return r.IsDraft, set
|
||||
}
|
||||
|
||||
func (r *Refund) CanDelete() bool {
|
||||
return r.IsDraft || r.Status == REFUND_PENDING
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package refund
|
||||
|
||||
import (
|
||||
"cloud.o-forge.io/core/oc-lib/logs"
|
||||
"cloud.o-forge.io/core/oc-lib/models/utils"
|
||||
"cloud.o-forge.io/core/oc-lib/tools"
|
||||
)
|
||||
|
||||
type refundMongoAccessor struct {
|
||||
utils.AbstractAccessor[*Refund]
|
||||
}
|
||||
|
||||
func NewAccessor(request *tools.APIRequest) *refundMongoAccessor {
|
||||
return &refundMongoAccessor{
|
||||
AbstractAccessor: utils.AbstractAccessor[*Refund]{
|
||||
Logger: logs.CreateLogger(tools.REFUND.String()),
|
||||
Request: request,
|
||||
Type: tools.REFUND,
|
||||
New: func() *Refund { return &Refund{} },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package subscription
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"cloud.o-forge.io/core/oc-lib/models/common/pricing"
|
||||
"cloud.o-forge.io/core/oc-lib/models/utils"
|
||||
"cloud.o-forge.io/core/oc-lib/tools"
|
||||
)
|
||||
|
||||
type SubscriptionStatus int
|
||||
|
||||
const (
|
||||
SUBSCRIPTION_PENDING SubscriptionStatus = iota // en attente de premier paiement
|
||||
SUBSCRIPTION_TRIAL // période d'essai
|
||||
SUBSCRIPTION_ACTIVE // actif
|
||||
SUBSCRIPTION_PAUSED // suspendu temporairement
|
||||
SUBSCRIPTION_CANCELLED // résilié par l'utilisateur
|
||||
SUBSCRIPTION_EXPIRED // date de fin dépassée
|
||||
)
|
||||
|
||||
func (s SubscriptionStatus) String() string {
|
||||
return [...]string{"pending", "trial", "active", "paused", "cancelled", "expired"}[s]
|
||||
}
|
||||
|
||||
func SubscriptionStatusList() []SubscriptionStatus {
|
||||
return []SubscriptionStatus{SUBSCRIPTION_PENDING, SUBSCRIPTION_TRIAL, SUBSCRIPTION_ACTIVE, SUBSCRIPTION_PAUSED, SUBSCRIPTION_CANCELLED, SUBSCRIPTION_EXPIRED}
|
||||
}
|
||||
|
||||
// SubscriptionItem représente un élément d'un abonnement (ressource louée).
|
||||
type SubscriptionItem struct {
|
||||
ResourceID string `json:"resource_id" bson:"resource_id"`
|
||||
ResourceType tools.DataType `json:"resource_type" bson:"resource_type"`
|
||||
Quantity int `json:"quantity" bson:"quantity"`
|
||||
UnitPrice float64 `json:"unit_price" bson:"unit_price"`
|
||||
}
|
||||
|
||||
// Subscription représente un abonnement mensuel ou annuel à des ressources.
|
||||
type Subscription struct {
|
||||
utils.AbstractObject
|
||||
SubscriberPeerID string `json:"subscriber_peer_id" bson:"subscriber_peer_id" validate:"required"`
|
||||
ProviderPeerID string `json:"provider_peer_id,omitempty" bson:"provider_peer_id,omitempty"`
|
||||
Status SubscriptionStatus `json:"status" bson:"status"`
|
||||
PlanType pricing.PaymentType `json:"plan_type" bson:"plan_type"` // PAY_EVERY_MONTH ou PAY_EVERY_YEAR
|
||||
Items []*SubscriptionItem `json:"items,omitempty" bson:"items,omitempty"`
|
||||
Amount float64 `json:"amount" bson:"amount"`
|
||||
Currency string `json:"currency" bson:"currency" default:"EUR"`
|
||||
StartDate time.Time `json:"start_date" bson:"start_date"`
|
||||
EndDate *time.Time `json:"end_date,omitempty" bson:"end_date,omitempty"`
|
||||
NextBillingDate time.Time `json:"next_billing_date" bson:"next_billing_date"`
|
||||
AutoRenew bool `json:"auto_renew" bson:"auto_renew" default:"true"`
|
||||
TrialEndDate *time.Time `json:"trial_end_date,omitempty" bson:"trial_end_date,omitempty"`
|
||||
DiscountIDs []string `json:"discount_ids,omitempty" bson:"discount_ids,omitempty"`
|
||||
CancelledAt *time.Time `json:"cancelled_at,omitempty" bson:"cancelled_at,omitempty"`
|
||||
CancelReason string `json:"cancel_reason,omitempty" bson:"cancel_reason,omitempty"`
|
||||
}
|
||||
|
||||
// newSubscription est le constructeur interne commun.
|
||||
func newSubscription(subscriberPeerID, providerPeerID string, items []*SubscriptionItem, amount float64, currency string, plan pricing.PaymentType, nextBilling time.Time) *Subscription {
|
||||
now := time.Now().UTC()
|
||||
return &Subscription{
|
||||
SubscriberPeerID: subscriberPeerID,
|
||||
ProviderPeerID: providerPeerID,
|
||||
Status: SUBSCRIPTION_PENDING,
|
||||
PlanType: plan,
|
||||
Items: items,
|
||||
Amount: amount,
|
||||
Currency: currency,
|
||||
StartDate: now,
|
||||
NextBillingDate: nextBilling,
|
||||
AutoRenew: true,
|
||||
}
|
||||
}
|
||||
|
||||
// NewWeeklySubscription crée un abonnement hebdomadaire.
|
||||
func NewWeeklySubscription(subscriberPeerID, providerPeerID string, items []*SubscriptionItem, amount float64, currency string) *Subscription {
|
||||
now := time.Now().UTC()
|
||||
return newSubscription(subscriberPeerID, providerPeerID, items, amount, currency, pricing.PAY_EVERY_WEEK, now.AddDate(0, 0, 7))
|
||||
}
|
||||
|
||||
// NewMonthlySubscription crée un abonnement mensuel.
|
||||
func NewMonthlySubscription(subscriberPeerID, providerPeerID string, items []*SubscriptionItem, amount float64, currency string) *Subscription {
|
||||
now := time.Now().UTC()
|
||||
return newSubscription(subscriberPeerID, providerPeerID, items, amount, currency, pricing.PAY_EVERY_MONTH, now.AddDate(0, 1, 0))
|
||||
}
|
||||
|
||||
// NewYearlySubscription crée un abonnement annuel.
|
||||
func NewYearlySubscription(subscriberPeerID, providerPeerID string, items []*SubscriptionItem, amount float64, currency string) *Subscription {
|
||||
now := time.Now().UTC()
|
||||
return newSubscription(subscriberPeerID, providerPeerID, items, amount, currency, pricing.PAY_EVERY_YEAR, now.AddDate(1, 0, 0))
|
||||
}
|
||||
|
||||
// NewTrialSubscription crée un abonnement mensuel avec période d'essai.
|
||||
func NewTrialSubscription(subscriberPeerID, providerPeerID string, items []*SubscriptionItem, amount float64, currency string, trialDays int) *Subscription {
|
||||
now := time.Now().UTC()
|
||||
trialEnd := now.AddDate(0, 0, trialDays)
|
||||
s := NewMonthlySubscription(subscriberPeerID, providerPeerID, items, amount, currency)
|
||||
s.Status = SUBSCRIPTION_TRIAL
|
||||
s.TrialEndDate = &trialEnd
|
||||
s.NextBillingDate = trialEnd
|
||||
return s
|
||||
}
|
||||
|
||||
// Activate passe l'abonnement au statut actif (après premier paiement).
|
||||
func (s *Subscription) Activate() {
|
||||
s.Status = SUBSCRIPTION_ACTIVE
|
||||
}
|
||||
|
||||
// Pause suspend l'abonnement.
|
||||
func (s *Subscription) Pause() {
|
||||
if s.Status == SUBSCRIPTION_ACTIVE {
|
||||
s.Status = SUBSCRIPTION_PAUSED
|
||||
}
|
||||
}
|
||||
|
||||
// Resume réactive un abonnement suspendu.
|
||||
func (s *Subscription) Resume() {
|
||||
if s.Status == SUBSCRIPTION_PAUSED {
|
||||
s.Status = SUBSCRIPTION_ACTIVE
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel résilie l'abonnement.
|
||||
func (s *Subscription) Cancel(reason string) {
|
||||
now := time.Now().UTC()
|
||||
s.Status = SUBSCRIPTION_CANCELLED
|
||||
s.CancelledAt = &now
|
||||
s.CancelReason = reason
|
||||
s.AutoRenew = false
|
||||
}
|
||||
|
||||
// Renew avance la prochaine date de facturation d'une période.
|
||||
func (s *Subscription) Renew() {
|
||||
switch s.PlanType {
|
||||
case pricing.PAY_EVERY_WEEK:
|
||||
s.NextBillingDate = s.NextBillingDate.AddDate(0, 0, 7)
|
||||
case pricing.PAY_EVERY_MONTH:
|
||||
s.NextBillingDate = s.NextBillingDate.AddDate(0, 1, 0)
|
||||
case pricing.PAY_EVERY_YEAR:
|
||||
s.NextBillingDate = s.NextBillingDate.AddDate(1, 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// IsExpired vérifie si l'abonnement a dépassé sa date de fin.
|
||||
func (s *Subscription) IsExpired() bool {
|
||||
if s.EndDate == nil {
|
||||
return false
|
||||
}
|
||||
return time.Now().UTC().After(*s.EndDate)
|
||||
}
|
||||
|
||||
// IsBillingDue vérifie si la prochaine échéance est atteinte.
|
||||
func (s *Subscription) IsBillingDue() bool {
|
||||
return s.Status == SUBSCRIPTION_ACTIVE && !time.Now().UTC().Before(s.NextBillingDate)
|
||||
}
|
||||
|
||||
// IsInTrial vérifie si l'abonnement est en période d'essai.
|
||||
func (s *Subscription) IsInTrial() bool {
|
||||
return s.Status == SUBSCRIPTION_TRIAL && s.TrialEndDate != nil && time.Now().UTC().Before(*s.TrialEndDate)
|
||||
}
|
||||
|
||||
// ComputeAmount recalcule le montant total depuis les items.
|
||||
func (s *Subscription) ComputeAmount() float64 {
|
||||
total := 0.0
|
||||
for _, item := range s.Items {
|
||||
total += item.UnitPrice * float64(item.Quantity)
|
||||
}
|
||||
s.Amount = total
|
||||
return total
|
||||
}
|
||||
|
||||
func (s *Subscription) GetAccessor(request *tools.APIRequest) utils.Accessor {
|
||||
return NewAccessor(request)
|
||||
}
|
||||
|
||||
func (s *Subscription) StoreDraftDefault() {
|
||||
s.IsDraft = true
|
||||
}
|
||||
|
||||
func (s *Subscription) CanUpdate(set utils.DBObject) (bool, utils.DBObject) {
|
||||
incoming := set.(*Subscription)
|
||||
if !s.IsDraft && s.Status != incoming.Status {
|
||||
return true, &Subscription{
|
||||
Status: incoming.Status,
|
||||
AutoRenew: incoming.AutoRenew,
|
||||
CancelReason: incoming.CancelReason,
|
||||
}
|
||||
}
|
||||
return s.IsDraft, set
|
||||
}
|
||||
|
||||
func (s *Subscription) CanDelete() bool {
|
||||
return s.IsDraft || s.Status == SUBSCRIPTION_CANCELLED
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package subscription
|
||||
|
||||
import (
|
||||
"cloud.o-forge.io/core/oc-lib/logs"
|
||||
"cloud.o-forge.io/core/oc-lib/models/utils"
|
||||
"cloud.o-forge.io/core/oc-lib/tools"
|
||||
)
|
||||
|
||||
type subscriptionMongoAccessor struct {
|
||||
utils.AbstractAccessor[*Subscription]
|
||||
}
|
||||
|
||||
func NewAccessor(request *tools.APIRequest) *subscriptionMongoAccessor {
|
||||
return &subscriptionMongoAccessor{
|
||||
AbstractAccessor: utils.AbstractAccessor[*Subscription]{
|
||||
Logger: logs.CreateLogger(tools.SUBSCRIPTION.String()),
|
||||
Request: request,
|
||||
Type: tools.SUBSCRIPTION,
|
||||
New: func() *Subscription { return &Subscription{} },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package billing_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"cloud.o-forge.io/core/oc-lib/models/billing"
|
||||
"cloud.o-forge.io/core/oc-lib/models/common/enum"
|
||||
"cloud.o-forge.io/core/oc-lib/models/order"
|
||||
"cloud.o-forge.io/core/oc-lib/models/utils"
|
||||
"cloud.o-forge.io/core/oc-lib/tools"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBill_StoreDraftDefault(t *testing.T) {
|
||||
b := &billing.Bill{}
|
||||
b.StoreDraftDefault()
|
||||
assert.True(t, b.IsDraft)
|
||||
}
|
||||
|
||||
func TestBill_CanDelete_Draft(t *testing.T) {
|
||||
b := &billing.Bill{}
|
||||
b.IsDraft = true
|
||||
assert.True(t, b.CanDelete())
|
||||
}
|
||||
|
||||
func TestBill_CanDelete_NonDraft(t *testing.T) {
|
||||
b := &billing.Bill{}
|
||||
b.IsDraft = false
|
||||
assert.False(t, b.CanDelete())
|
||||
}
|
||||
|
||||
func TestBill_CanUpdate_StatusChange_NonDraft(t *testing.T) {
|
||||
b := &billing.Bill{Status: enum.PENDING}
|
||||
b.IsDraft = false
|
||||
set := &billing.Bill{Status: enum.PAID}
|
||||
ok, returned := b.CanUpdate(set)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, enum.PAID, returned.(*billing.Bill).Status)
|
||||
}
|
||||
|
||||
func TestBill_CanUpdate_SameStatus_NonDraft(t *testing.T) {
|
||||
b := &billing.Bill{Status: enum.PENDING}
|
||||
b.IsDraft = false
|
||||
set := &billing.Bill{Status: enum.PENDING}
|
||||
ok, _ := b.CanUpdate(set)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestBill_CanUpdate_Draft(t *testing.T) {
|
||||
b := &billing.Bill{Status: enum.PENDING}
|
||||
b.IsDraft = true
|
||||
set := &billing.Bill{Status: enum.PAID}
|
||||
ok, _ := b.CanUpdate(set)
|
||||
assert.True(t, ok)
|
||||
}
|
||||
|
||||
func TestBill_GetAccessor(t *testing.T) {
|
||||
b := &billing.Bill{}
|
||||
acc := b.GetAccessor(&tools.APIRequest{})
|
||||
assert.NotNil(t, acc)
|
||||
}
|
||||
|
||||
func TestBill_GetAccessor_NilRequest(t *testing.T) {
|
||||
b := &billing.Bill{}
|
||||
acc := b.GetAccessor(nil)
|
||||
assert.NotNil(t, acc)
|
||||
}
|
||||
|
||||
func TestGenerateBill_Basic(t *testing.T) {
|
||||
o := &order.Order{
|
||||
AbstractObject: utils.AbstractObject{UUID: "order-uuid-1"},
|
||||
}
|
||||
req := &tools.APIRequest{PeerID: "peer-abc"}
|
||||
b, err := billing.GenerateBill(o, req)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, b)
|
||||
assert.Equal(t, "order-uuid-1", b.OrderID)
|
||||
assert.Equal(t, enum.PENDING, b.Status)
|
||||
assert.False(t, b.IsDraft)
|
||||
assert.Contains(t, b.Name, "peer-abc")
|
||||
}
|
||||
|
||||
func TestBill_SumUpBill_NoSubOrders(t *testing.T) {
|
||||
b := &billing.Bill{Total: 0}
|
||||
result, err := b.SumUpBill(nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0.0, result.Total)
|
||||
}
|
||||
Reference in New Issue
Block a user