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 }