Skip to content

Commit

Permalink
feat: p/subscription (#2116)
Browse files Browse the repository at this point in the history
I've created this `subscription` package based on this
[PR](#1025) from @moul, I've
integrated two types of subscription, a recurring payment subscription
and a lifetime subscription. Do you have any ideas for other types of
subscription that would be relevant to this package?

<details><summary>Contributors' checklist...</summary>

- [ ] Added new tests, or not needed, or not feasible
- [ ] Provided an example (e.g. screenshot) to aid review or the PR is
self-explanatory
- [ ] Updated the official documentation or not needed
- [ ] No breaking changes were made, or a `BREAKING CHANGE: xxx` message
was included in the description
- [ ] Added references to related issues and PRs
- [ ] Provided any useful hints for running manual tests
- [ ] Added new benchmarks to [generated
graphs](https://gnoland.github.io/benchmarks), if any. More info
[here](https://github.com/gnolang/gno/blob/master/.benchmarks/README.md).
</details>

---------

Co-authored-by: Manfred Touron <94029+moul@users.noreply.github.com>
  • Loading branch information
kazai777 and moul authored Aug 22, 2024
1 parent aae5d49 commit c8e717a
Show file tree
Hide file tree
Showing 11 changed files with 540 additions and 0 deletions.
66 changes: 66 additions & 0 deletions examples/gno.land/p/demo/subscription/doc.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Package subscription provides a flexible system for managing both recurring and
// lifetime subscriptions in Gno applications. It enables developers to handle
// payment-based access control for services or products. The library supports
// both subscriptions requiring periodic payments (recurring) and one-time payments
// (lifetime). Subscriptions are tracked using an AVL tree for efficient management
// of subscription statuses.
//
// Usage:
//
// Import the required sub-packages (`recurring` and/or `lifetime`) to manage specific
// subscription types. The methods provided allow users to subscribe, check subscription
// status, and manage payments.
//
// Recurring Subscription:
//
// Recurring subscriptions require periodic payments to maintain access.
// Users pay to extend their access for a specific duration.
//
// Example:
//
// // Create a recurring subscription requiring 100 ugnot every 30 days
// recSub := recurring.NewRecurringSubscription(time.Hour * 24 * 30, 100)
//
// // Process payment for the recurring subscription
// recSub.Subscribe()
//
// // Gift a recurring subscription to another user
// recSub.GiftSubscription(recipientAddress)
//
// // Check if a user has a valid subscription
// recSub.HasValidSubscription(addr)
//
// // Get the expiration date of the subscription
// recSub.GetExpiration(caller)
//
// // Update the subscription amount to 200 ugnot
// recSub.UpdateAmount(200)
//
// // Get the current subscription amount
// recSub.GetAmount()
//
// Lifetime Subscription:
//
// Lifetime subscriptions require a one-time payment for permanent access.
// Once paid, users have indefinite access without further payments.
//
// Example:
//
// // Create a lifetime subscription costing 500 ugnot
// lifeSub := lifetime.NewLifetimeSubscription(500)
//
// // Process payment for lifetime access
// lifeSub.Subscribe()
//
// // Gift a lifetime subscription to another user
// lifeSub.GiftSubscription(recipientAddress)
//
// // Check if a user has a valid subscription
// lifeSub.HasValidSubscription(addr)
//
// // Update the lifetime subscription amount to 1000 ugnot
// lifeSub.UpdateAmount(1000)
//
// // Get the current lifetime subscription amount
// lifeSub.GetAmount()
package subscription
1 change: 1 addition & 0 deletions examples/gno.land/p/demo/subscription/gno.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module gno.land/p/demo/subscription
10 changes: 10 additions & 0 deletions examples/gno.land/p/demo/subscription/lifetime/errors.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package lifetime

import "errors"

var (
ErrNoSub = errors.New("lifetime subscription: no active subscription found")
ErrAmt = errors.New("lifetime subscription: payment amount does not match the required subscription amount")
ErrAlreadySub = errors.New("lifetime subscription: this address already has an active lifetime subscription")
ErrNotAuthorized = errors.New("lifetime subscription: action not authorized")
)
8 changes: 8 additions & 0 deletions examples/gno.land/p/demo/subscription/lifetime/gno.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module gno.land/p/demo/subscription/lifetime

require (
gno.land/p/demo/avl v0.0.0-latest
gno.land/p/demo/ownable v0.0.0-latest
gno.land/p/demo/testutils v0.0.0-latest
gno.land/p/demo/uassert v0.0.0-latest
)
81 changes: 81 additions & 0 deletions examples/gno.land/p/demo/subscription/lifetime/lifetime.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package lifetime

import (
"std"

"gno.land/p/demo/avl"
"gno.land/p/demo/ownable"
)

// LifetimeSubscription represents a subscription that requires only a one-time payment.
// It grants permanent access to a service or product.
type LifetimeSubscription struct {
ownable.Ownable
amount int64
subs *avl.Tree // std.Address -> bool
}

// NewLifetimeSubscription creates and returns a new lifetime subscription.
func NewLifetimeSubscription(amount int64) *LifetimeSubscription {
return &LifetimeSubscription{
Ownable: *ownable.New(),
amount: amount,
subs: avl.NewTree(),
}
}

// processSubscription handles the subscription process for a given receiver.
func (ls *LifetimeSubscription) processSubscription(receiver std.Address) error {
amount := std.GetOrigSend()

if amount.AmountOf("ugnot") != ls.amount {
return ErrAmt
}

_, exists := ls.subs.Get(receiver.String())

if exists {
return ErrAlreadySub
}

ls.subs.Set(receiver.String(), true)

return nil
}

// Subscribe processes the payment for a lifetime subscription.
func (ls *LifetimeSubscription) Subscribe() error {
caller := std.PrevRealm().Addr()
return ls.processSubscription(caller)
}

// GiftSubscription allows the caller to pay for a lifetime subscription for another user.
func (ls *LifetimeSubscription) GiftSubscription(receiver std.Address) error {
return ls.processSubscription(receiver)
}

// HasValidSubscription checks if the given address has an active lifetime subscription.
func (ls *LifetimeSubscription) HasValidSubscription(addr std.Address) error {
_, exists := ls.subs.Get(addr.String())

if !exists {
return ErrNoSub
}

return nil
}

// UpdateAmount allows the owner of the LifetimeSubscription contract to update the subscription price.
func (ls *LifetimeSubscription) UpdateAmount(newAmount int64) error {
if err := ls.CallerIsOwner(); err != nil {
return ErrNotAuthorized
}

ls.amount = newAmount
return nil
}

// GetAmount returns the current subscription price.
func (ls *LifetimeSubscription) GetAmount() int64 {
return ls.amount
}
105 changes: 105 additions & 0 deletions examples/gno.land/p/demo/subscription/lifetime/lifetime_test.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package lifetime

import (
"std"
"testing"

"gno.land/p/demo/testutils"
"gno.land/p/demo/uassert"
)

var (
alice = testutils.TestAddress("alice")
bob = testutils.TestAddress("bob")
charlie = testutils.TestAddress("charlie")
)

func TestLifetimeSubscription(t *testing.T) {
std.TestSetRealm(std.NewUserRealm(alice))
ls := NewLifetimeSubscription(1000)

std.TestSetOrigSend([]std.Coin{{Denom: "ugnot", Amount: 1000}}, nil)
err := ls.Subscribe()
uassert.NoError(t, err, "Expected ProcessPayment to succeed")

err = ls.HasValidSubscription(std.PrevRealm().Addr())
uassert.NoError(t, err, "Expected Alice to have access")
}

func TestLifetimeSubscriptionGift(t *testing.T) {
std.TestSetRealm(std.NewUserRealm(alice))
ls := NewLifetimeSubscription(1000)

std.TestSetOrigSend([]std.Coin{{Denom: "ugnot", Amount: 1000}}, nil)
err := ls.GiftSubscription(bob)
uassert.NoError(t, err, "Expected ProcessPaymentGift to succeed for Bob")

err = ls.HasValidSubscription(bob)
uassert.NoError(t, err, "Expected Bob to have access")

err = ls.HasValidSubscription(charlie)
uassert.Error(t, err, "Expected Charlie to fail access check")
}

func TestUpdateAmountAuthorization(t *testing.T) {
std.TestSetRealm(std.NewUserRealm(alice))
ls := NewLifetimeSubscription(1000)

err := ls.UpdateAmount(2000)
uassert.NoError(t, err, "Expected Alice to succeed in updating amount")

std.TestSetOrigCaller(bob)

err = ls.UpdateAmount(3000)
uassert.Error(t, err, "Expected Bob to fail when updating amount")
}

func TestIncorrectPaymentAmount(t *testing.T) {
std.TestSetRealm(std.NewUserRealm(alice))
ls := NewLifetimeSubscription(1000)

std.TestSetOrigSend([]std.Coin{{Denom: "ugnot", Amount: 500}}, nil)
err := ls.Subscribe()
uassert.Error(t, err, "Expected payment to fail with incorrect amount")
}

func TestMultipleSubscriptionAttempts(t *testing.T) {
std.TestSetRealm(std.NewUserRealm(alice))
ls := NewLifetimeSubscription(1000)

std.TestSetOrigSend([]std.Coin{{Denom: "ugnot", Amount: 1000}}, nil)
err := ls.Subscribe()
uassert.NoError(t, err, "Expected first subscription to succeed")

std.TestSetOrigSend([]std.Coin{{Denom: "ugnot", Amount: 1000}}, nil)
err = ls.Subscribe()
uassert.Error(t, err, "Expected second subscription to fail as Alice is already subscribed")
}

func TestGiftSubscriptionWithIncorrectAmount(t *testing.T) {
std.TestSetRealm(std.NewUserRealm(alice))
ls := NewLifetimeSubscription(1000)

std.TestSetOrigSend([]std.Coin{{Denom: "ugnot", Amount: 500}}, nil)
err := ls.GiftSubscription(bob)
uassert.Error(t, err, "Expected gift subscription to fail with incorrect amount")

err = ls.HasValidSubscription(bob)
uassert.Error(t, err, "Expected Bob to not have access after incorrect gift subscription")
}

func TestUpdateAmountEffectiveness(t *testing.T) {
std.TestSetRealm(std.NewUserRealm(alice))
ls := NewLifetimeSubscription(1000)

err := ls.UpdateAmount(2000)
uassert.NoError(t, err, "Expected Alice to succeed in updating amount")

std.TestSetOrigSend([]std.Coin{{Denom: "ugnot", Amount: 1000}}, nil)
err = ls.Subscribe()
uassert.Error(t, err, "Expected subscription to fail with old amount after update")

std.TestSetOrigSend([]std.Coin{{Denom: "ugnot", Amount: 2000}}, nil)
err = ls.Subscribe()
uassert.NoError(t, err, "Expected subscription to succeed with new amount")
}
11 changes: 11 additions & 0 deletions examples/gno.land/p/demo/subscription/recurring/errors.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package recurring

import "errors"

var (
ErrNoSub = errors.New("recurring subscription: no active subscription found")
ErrSubExpired = errors.New("recurring subscription: your subscription has expired")
ErrAmt = errors.New("recurring subscription: payment amount does not match the required subscription amount")
ErrAlreadySub = errors.New("recurring subscription: this address already has an active subscription")
ErrNotAuthorized = errors.New("recurring subscription: action not authorized")
)
8 changes: 8 additions & 0 deletions examples/gno.land/p/demo/subscription/recurring/gno.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module gno.land/p/demo/subscription/recurring

require (
gno.land/p/demo/avl v0.0.0-latest
gno.land/p/demo/ownable v0.0.0-latest
gno.land/p/demo/testutils v0.0.0-latest
gno.land/p/demo/uassert v0.0.0-latest
)
Loading

0 comments on commit c8e717a

Please sign in to comment.