422 lines
13 KiB
Go
422 lines
13 KiB
Go
|
|
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
|
|||
|
|
}
|