Files
oc-lib/models/billing/bill.go
T
2026-05-27 15:50:23 +02:00

422 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}