subscriptions.go raw

   1  //go:build js && wasm
   2  
   3  package wasmdb
   4  
   5  import (
   6  	"bytes"
   7  	"encoding/binary"
   8  	"encoding/json"
   9  	"errors"
  10  	"time"
  11  
  12  	"github.com/aperturerobotics/go-indexeddb/idb"
  13  
  14  	"next.orly.dev/pkg/database"
  15  )
  16  
  17  const (
  18  	// SubscriptionsStoreName is the object store for payment subscriptions
  19  	SubscriptionsStoreName = "subscriptions"
  20  
  21  	// PaymentsPrefix is the key prefix for payment records
  22  	PaymentsPrefix = "payment:"
  23  )
  24  
  25  // GetSubscription retrieves a subscription for a pubkey
  26  func (w *W) GetSubscription(pubkey []byte) (*database.Subscription, error) {
  27  	key := "sub:" + string(pubkey)
  28  	data, err := w.getStoreValue(SubscriptionsStoreName, key)
  29  	if err != nil {
  30  		return nil, err
  31  	}
  32  	if data == nil {
  33  		return nil, nil
  34  	}
  35  
  36  	return w.deserializeSubscription(data)
  37  }
  38  
  39  // IsSubscriptionActive checks if a pubkey has an active subscription
  40  // If no subscription exists, creates a 30-day trial
  41  func (w *W) IsSubscriptionActive(pubkey []byte) (bool, error) {
  42  	key := "sub:" + string(pubkey)
  43  	data, err := w.getStoreValue(SubscriptionsStoreName, key)
  44  	if err != nil {
  45  		return false, err
  46  	}
  47  
  48  	now := time.Now()
  49  
  50  	if data == nil {
  51  		// Create new trial subscription
  52  		sub := &database.Subscription{
  53  			TrialEnd: now.AddDate(0, 0, 30),
  54  		}
  55  		subData := w.serializeSubscription(sub)
  56  		if err := w.setStoreValue(SubscriptionsStoreName, key, subData); err != nil {
  57  			return false, err
  58  		}
  59  		return true, nil
  60  	}
  61  
  62  	sub, err := w.deserializeSubscription(data)
  63  	if err != nil {
  64  		return false, err
  65  	}
  66  
  67  	// Active if within trial or paid period
  68  	return now.Before(sub.TrialEnd) || (!sub.PaidUntil.IsZero() && now.Before(sub.PaidUntil)), nil
  69  }
  70  
  71  // ExtendSubscription extends a subscription by the given number of days
  72  func (w *W) ExtendSubscription(pubkey []byte, days int) error {
  73  	if days <= 0 {
  74  		return errors.New("invalid days")
  75  	}
  76  
  77  	key := "sub:" + string(pubkey)
  78  	data, err := w.getStoreValue(SubscriptionsStoreName, key)
  79  	if err != nil {
  80  		return err
  81  	}
  82  
  83  	now := time.Now()
  84  	var sub *database.Subscription
  85  
  86  	if data == nil {
  87  		// Create new subscription
  88  		sub = &database.Subscription{
  89  			PaidUntil: now.AddDate(0, 0, days),
  90  		}
  91  	} else {
  92  		sub, err = w.deserializeSubscription(data)
  93  		if err != nil {
  94  			return err
  95  		}
  96  		// Extend from current paid date if still active, otherwise from now
  97  		extendFrom := now
  98  		if !sub.PaidUntil.IsZero() && sub.PaidUntil.After(now) {
  99  			extendFrom = sub.PaidUntil
 100  		}
 101  		sub.PaidUntil = extendFrom.AddDate(0, 0, days)
 102  	}
 103  
 104  	// Serialize and store
 105  	subData := w.serializeSubscription(sub)
 106  	return w.setStoreValue(SubscriptionsStoreName, key, subData)
 107  }
 108  
 109  // RecordPayment records a payment for a pubkey
 110  func (w *W) RecordPayment(pubkey []byte, amount int64, invoice, preimage string) error {
 111  	now := time.Now()
 112  	payment := &database.Payment{
 113  		Amount:    amount,
 114  		Timestamp: now,
 115  		Invoice:   invoice,
 116  		Preimage:  preimage,
 117  	}
 118  
 119  	data := w.serializePayment(payment)
 120  
 121  	// Create unique key with timestamp
 122  	key := PaymentsPrefix + string(pubkey) + ":" + now.Format(time.RFC3339Nano)
 123  	return w.setStoreValue(SubscriptionsStoreName, key, data)
 124  }
 125  
 126  // GetPaymentHistory retrieves all payments for a pubkey
 127  func (w *W) GetPaymentHistory(pubkey []byte) ([]database.Payment, error) {
 128  	prefix := PaymentsPrefix + string(pubkey) + ":"
 129  
 130  	tx, err := w.db.Transaction(idb.TransactionReadOnly, SubscriptionsStoreName)
 131  	if err != nil {
 132  		return nil, err
 133  	}
 134  
 135  	store, err := tx.ObjectStore(SubscriptionsStoreName)
 136  	if err != nil {
 137  		return nil, err
 138  	}
 139  
 140  	var payments []database.Payment
 141  
 142  	cursorReq, err := store.OpenCursor(idb.CursorNext)
 143  	if err != nil {
 144  		return nil, err
 145  	}
 146  
 147  	prefixBytes := []byte(prefix)
 148  
 149  	err = cursorReq.Iter(w.ctx, func(cursor *idb.CursorWithValue) error {
 150  		keyVal, keyErr := cursor.Key()
 151  		if keyErr != nil {
 152  			return keyErr
 153  		}
 154  
 155  		keyBytes := safeValueToBytes(keyVal)
 156  		if bytes.HasPrefix(keyBytes, prefixBytes) {
 157  			val, valErr := cursor.Value()
 158  			if valErr != nil {
 159  				return valErr
 160  			}
 161  			valBytes := safeValueToBytes(val)
 162  			if payment, err := w.deserializePayment(valBytes); err == nil {
 163  				payments = append(payments, *payment)
 164  			}
 165  		}
 166  
 167  		return cursor.Continue()
 168  	})
 169  
 170  	if err != nil {
 171  		return nil, err
 172  	}
 173  
 174  	return payments, nil
 175  }
 176  
 177  // ExtendBlossomSubscription extends a blossom subscription with storage quota
 178  func (w *W) ExtendBlossomSubscription(pubkey []byte, level string, storageMB int64, days int) error {
 179  	if days <= 0 {
 180  		return errors.New("invalid days")
 181  	}
 182  
 183  	key := "sub:" + string(pubkey)
 184  	data, err := w.getStoreValue(SubscriptionsStoreName, key)
 185  	if err != nil {
 186  		return err
 187  	}
 188  
 189  	now := time.Now()
 190  	var sub *database.Subscription
 191  
 192  	if data == nil {
 193  		sub = &database.Subscription{
 194  			PaidUntil:      now.AddDate(0, 0, days),
 195  			BlossomLevel:   level,
 196  			BlossomStorage: storageMB,
 197  		}
 198  	} else {
 199  		sub, err = w.deserializeSubscription(data)
 200  		if err != nil {
 201  			return err
 202  		}
 203  
 204  		// Extend from current paid date if still active
 205  		extendFrom := now
 206  		if !sub.PaidUntil.IsZero() && sub.PaidUntil.After(now) {
 207  			extendFrom = sub.PaidUntil
 208  		}
 209  		sub.PaidUntil = extendFrom.AddDate(0, 0, days)
 210  
 211  		// Set level and accumulate storage
 212  		sub.BlossomLevel = level
 213  		if sub.BlossomStorage > 0 && sub.PaidUntil.After(now) {
 214  			sub.BlossomStorage += storageMB
 215  		} else {
 216  			sub.BlossomStorage = storageMB
 217  		}
 218  	}
 219  
 220  	subData := w.serializeSubscription(sub)
 221  	return w.setStoreValue(SubscriptionsStoreName, key, subData)
 222  }
 223  
 224  // GetBlossomStorageQuota returns the storage quota for a pubkey
 225  func (w *W) GetBlossomStorageQuota(pubkey []byte) (quotaMB int64, err error) {
 226  	sub, err := w.GetSubscription(pubkey)
 227  	if err != nil {
 228  		return 0, err
 229  	}
 230  	if sub == nil {
 231  		return 0, nil
 232  	}
 233  	// Only return quota if subscription is active
 234  	if sub.PaidUntil.IsZero() || time.Now().After(sub.PaidUntil) {
 235  		return 0, nil
 236  	}
 237  	return sub.BlossomStorage, nil
 238  }
 239  
 240  // IsFirstTimeUser checks if a pubkey is a first-time user (no subscription history)
 241  func (w *W) IsFirstTimeUser(pubkey []byte) (bool, error) {
 242  	key := "firstlogin:" + string(pubkey)
 243  	data, err := w.getStoreValue(SubscriptionsStoreName, key)
 244  	if err != nil {
 245  		return false, err
 246  	}
 247  
 248  	if data == nil {
 249  		// First time - record the login
 250  		now := time.Now()
 251  		loginData, _ := json.Marshal(map[string]interface{}{
 252  			"first_login": now,
 253  		})
 254  		_ = w.setStoreValue(SubscriptionsStoreName, key, loginData)
 255  		return true, nil
 256  	}
 257  
 258  	return false, nil
 259  }
 260  
 261  // serializeSubscription converts a subscription to bytes using JSON
 262  func (w *W) serializeSubscription(s *database.Subscription) []byte {
 263  	data, _ := json.Marshal(s)
 264  	return data
 265  }
 266  
 267  // deserializeSubscription converts bytes to a subscription
 268  func (w *W) deserializeSubscription(data []byte) (*database.Subscription, error) {
 269  	s := &database.Subscription{}
 270  	if err := json.Unmarshal(data, s); err != nil {
 271  		return nil, err
 272  	}
 273  	return s, nil
 274  }
 275  
 276  // serializePayment converts a payment to bytes
 277  func (w *W) serializePayment(p *database.Payment) []byte {
 278  	buf := new(bytes.Buffer)
 279  
 280  	// Amount (8 bytes)
 281  	amt := make([]byte, 8)
 282  	binary.BigEndian.PutUint64(amt, uint64(p.Amount))
 283  	buf.Write(amt)
 284  
 285  	// Timestamp (8 bytes)
 286  	ts := make([]byte, 8)
 287  	binary.BigEndian.PutUint64(ts, uint64(p.Timestamp.Unix()))
 288  	buf.Write(ts)
 289  
 290  	// Invoice length (4 bytes) + Invoice
 291  	invBytes := []byte(p.Invoice)
 292  	invLen := make([]byte, 4)
 293  	binary.BigEndian.PutUint32(invLen, uint32(len(invBytes)))
 294  	buf.Write(invLen)
 295  	buf.Write(invBytes)
 296  
 297  	// Preimage length (4 bytes) + Preimage
 298  	preBytes := []byte(p.Preimage)
 299  	preLen := make([]byte, 4)
 300  	binary.BigEndian.PutUint32(preLen, uint32(len(preBytes)))
 301  	buf.Write(preLen)
 302  	buf.Write(preBytes)
 303  
 304  	return buf.Bytes()
 305  }
 306  
 307  // deserializePayment converts bytes to a payment
 308  func (w *W) deserializePayment(data []byte) (*database.Payment, error) {
 309  	if len(data) < 24 { // 8 + 8 + 4 + 4 minimum
 310  		return nil, errors.New("invalid payment data")
 311  	}
 312  
 313  	p := &database.Payment{}
 314  
 315  	p.Amount = int64(binary.BigEndian.Uint64(data[0:8]))
 316  	p.Timestamp = time.Unix(int64(binary.BigEndian.Uint64(data[8:16])), 0)
 317  
 318  	invLen := binary.BigEndian.Uint32(data[16:20])
 319  	if len(data) < int(20+invLen+4) {
 320  		return nil, errors.New("invalid invoice length")
 321  	}
 322  	p.Invoice = string(data[20 : 20+invLen])
 323  
 324  	offset := 20 + invLen
 325  	preLen := binary.BigEndian.Uint32(data[offset : offset+4])
 326  	if len(data) < int(offset+4+preLen) {
 327  		return nil, errors.New("invalid preimage length")
 328  	}
 329  	p.Preimage = string(data[offset+4 : offset+4+preLen])
 330  
 331  	return p, nil
 332  }
 333