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