From 9f31c024f54ab822c145700c3decc8fc777aab7d Mon Sep 17 00:00:00 2001
From: Immanuel Onyeka <immanuel@debian-BULLSEYE-live-builder-AMD64>
Date: Sun, 2 Jun 2024 13:53:03 -0400
Subject: [PATCH] Add code for users to unsubscribe from settings

Also embeds /assets, /migrations, and views/ directories in Go binary to
make deployment simpler.
---
 components/settings.vue                |  9 ++-
 components/unsubscribe.vue             |  5 +-
 migrations/0_29092022_setup_tables.sql |  1 +
 skouter.go                             | 84 +++++++++++++++++++++++---
 4 files changed, 85 insertions(+), 14 deletions(-)

diff --git a/components/settings.vue b/components/settings.vue
index 5cc1411..0053b56 100644
--- a/components/settings.vue
+++ b/components/settings.vue
@@ -54,7 +54,9 @@
 <section class="form inputs special">
 <h3>Subscriptions</h3>
 <label for="">Standard Plan ($49/month)</label>
-<button @click="() => unsubing = true">Unsubscribe</button>
+<button @click="() => unsubing = true">
+{{props.user.sub.cancelAtEnd ? "Subscribe" : "Unsubscribe"}}
+</button>
 <label for="">Newsletter</label>
 <button @click="changeNewsSub">
   {{props.user.newsletter ? "Unsubscribe" : "Subscribe"}}
@@ -81,7 +83,10 @@
 <button>Confirm</button>
 </Dialog>
 
-<UnsubPrompt v-if="unsubing" :token="token" @close="() => unsubing = false"/>
+<UnsubPrompt v-if="unsubing" :token="token"
+@close="() => unsubing = false"
+@cancelSub="() => { $emit('updateUser'); unsubing = false }"
+/>
 
 </div>
 </template>
diff --git a/components/unsubscribe.vue b/components/unsubscribe.vue
index 4096333..9ab7986 100644
--- a/components/unsubscribe.vue
+++ b/components/unsubscribe.vue
@@ -11,11 +11,10 @@
 <script setup>
 import Dialog from "./dialog.vue"
 
-const emit = defineEmits(['close'])
+const emit = defineEmits(['close', 'cancelSub'])
 const props = defineProps(['token'])
 
 function unsubscribe() {
-  console.log(props.user)
   fetch(`/api/user/unsubscribe`,
 	    {method: 'GET',
     		headers: {
@@ -23,7 +22,7 @@ function unsubscribe() {
         	"Authorization": `Bearer ${props.token}`,
     		},
         }).then(resp => {
-          if (resp.ok) emit('updateUser')
+          if (resp.ok) emit('cancelSub')
         })
 }
 </script>
diff --git a/migrations/0_29092022_setup_tables.sql b/migrations/0_29092022_setup_tables.sql
index 14956eb..73d9a0b 100644
--- a/migrations/0_29092022_setup_tables.sql
+++ b/migrations/0_29092022_setup_tables.sql
@@ -77,6 +77,7 @@ CREATE TABLE subscription (
 	payment_status	VARCHAR(50) NOT NULL,
 	current_period_end 		INT DEFAULT 0,
 	current_period_start 		INT DEFAULT 0,
+	cancel_at_end 		BOOL DEFAULT 0,
 	PRIMARY KEY (`id`),
 	FOREIGN KEY (user_id) REFERENCES user(id)
 );
diff --git a/skouter.go b/skouter.go
index cb00c81..074f187 100644
--- a/skouter.go
+++ b/skouter.go
@@ -35,6 +35,7 @@ import (
 	"image"
 	_ "image/jpeg"
 	"image/png"
+	"embed"
 )
 
 type Config struct {
@@ -71,6 +72,7 @@ type Subscription struct {
 	PriceId	string  `json:"priceId"`
 	Start	int     `json:"start"`
 	End	int     `json:"end"`
+	CancelAtEnd	bool	`json:"cancelAtEnd"`
 	ClientSecret	string  `json:"clientSecret,omitempty"`
 	PaymentStatus	string  `json:"paymentStatus"`
 	Status	string  `json:"status"`
@@ -302,6 +304,15 @@ var hookKeys = HookKeys{
 	SubDeleted: "",
 }
 
+//go:embed assets
+var assets embed.FS
+
+//go:embed migrations
+var migrations embed.FS
+
+//go:embed views
+var views embed.FS
+
 var standardPriceId = "price_1OZLK9BPMoXn2pf9kuTAf8rs"
 
 // Used to validate claim in JWT token body. Checks if user id is greater than
@@ -366,7 +377,7 @@ func cachePdf(name string) Page {
 		"views/report/summary.tpl",
 		"views/report/comparison.tpl"}
 
-	tpl := template.Must(template.New("master.tpl").Funcs(fm).ParseFiles(p...))
+	tpl := template.Must(template.New("master.tpl").Funcs(fm).ParseFS(views, p...))
 	return Page{tpl: tpl, Title: "", Name: name}
 }
 
@@ -1395,8 +1406,10 @@ func querySub(db *sql.DB, id int) (Subscription, error) {
 	customer_id,
 	current_period_end,
 	current_period_start,
+	cancel_at_end,
 	client_secret,
-	payment_status
+	payment_status,
+	status
 	FROM subscription WHERE id = ?
 	`
 	row := db.QueryRow(query, id)
@@ -1408,8 +1421,10 @@ func querySub(db *sql.DB, id int) (Subscription, error) {
 		&s.CustomerId,
 		&s.End,
 		&s.Start,
+		&s.CancelAtEnd,
 		&s.ClientSecret,
 		&s.PaymentStatus,
+		&s.Status,
 	)
 
 	return s, err
@@ -1520,7 +1535,7 @@ func insertUser(db *sql.DB, user User) (int, error) {
 	)
 	VALUES (?, ?, ?, sha2(?, 256), ?, ?, ?, ?, ?, ?,
 	CASE @b := ? WHEN 0 THEN NULL ELSE @b END,
-	?, NOW(), NOW())
+	?, ?, NOW(), NOW())
 	RETURNING id 
 	`
 
@@ -1564,11 +1579,12 @@ func (sub *Subscription) insertSub(db *sql.DB) (error) {
 		price_id,
 		current_period_end,
 		current_period_start,
+		cancel_at_end,
 		client_secret,
 		payment_status,
 		status
 	)
-	VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+	VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
 	RETURNING id
 	`
 	
@@ -1579,6 +1595,7 @@ func (sub *Subscription) insertSub(db *sql.DB) (error) {
 		sub.PriceId,
 		sub.End,
 		sub.Start,
+		sub.CancelAtEnd,
 		sub.ClientSecret,
 		sub.PaymentStatus,
 		sub.Status,
@@ -1598,8 +1615,9 @@ func (sub *Subscription) updateSub(db *sql.DB) error {
 		@b := ? WHEN 0 THEN current_period_end ELSE @b END,
 		current_period_start = CASE
 		@c := ? WHEN 0 THEN current_period_start ELSE @c END,
-		payment_status = CASE @d := ? WHEN '' THEN client_secret ELSE @d END,
-		status = CASE @e := ? WHEN '' THEN client_secret ELSE @e END
+		payment_status = CASE @d := ? WHEN '' THEN payment_status ELSE @d END,
+		status = CASE @e := ? WHEN '' THEN status ELSE @e END,
+		cancel_at_end = ?
 	WHERE id = ?
 	`
 	
@@ -1609,8 +1627,10 @@ func (sub *Subscription) updateSub(db *sql.DB) error {
 		sub.Start,
 		sub.PaymentStatus,
 		sub.Status,
+		sub.CancelAtEnd,
 		sub.Id,
 	)
+
 	if err != nil { return err }
 
 	return err
@@ -3162,6 +3182,17 @@ func createTrialSubscription(cid string) (*stripe.Subscription, error) {
     return s, err
 }
 
+// Cancel or uncancel subscription at the end of the current billing cycle.
+func (sub *Subscription) CancelSubscription(cancel bool) (*stripe.Subscription, error) {
+
+    subscriptionParams := &stripe.SubscriptionParams{
+        CancelAtPeriodEnd: stripe.Bool(cancel),
+    }
+    s, err := subscription.Update(sub.StripeId, subscriptionParams)
+    
+    return s, err
+}
+
 func ( user *User ) SyncSub( sub *stripe.Subscription, db *sql.DB ) error {
 	var err error
 	
@@ -3174,6 +3205,7 @@ func ( user *User ) SyncSub( sub *stripe.Subscription, db *sql.DB ) error {
 	user.Sub.ClientSecret = sub.LatestInvoice.PaymentIntent.ClientSecret
 	user.Sub.PaymentStatus = string(sub.LatestInvoice.PaymentIntent.Status)
 	user.Sub.Status = string(sub.Status)
+	user.Sub.CancelAtEnd = sub.CancelAtPeriodEnd
 	
 	if user.Sub.Id != 0 {
 		err = user.Sub.insertSub(db)
@@ -3312,6 +3344,36 @@ func trialSubscribe(w http.ResponseWriter, db *sql.DB, r *http.Request) {
 	json.NewEncoder(w).Encode(user.Sub)
 }
 
+// Sets Stripe subscription to cancel automatically at end of current period,
+// and updates subscription's 'CancelAtEnd' to true. If user's subscription is
+// already canceled, it will uncancel the subscription.
+func unsubscribe(w http.ResponseWriter, db *sql.DB, r *http.Request) {
+	stripe.Key = os.Getenv("STRIPE_SECRET_KEY")
+	claims, err := getClaims(r)
+	user, err := queryUser(db, claims.Id)
+
+	if err != nil {
+		w.WriteHeader(422)
+		return
+	}
+	
+	user.querySub(db)
+	_, err = user.Sub.CancelSubscription(!user.Sub.CancelAtEnd)
+	
+	if err != nil {
+		http.Error(w, err.Error(), 500)
+		return
+	}
+	
+	user.Sub.CancelAtEnd = !user.Sub.CancelAtEnd
+	err = user.Sub.updateSub(db)
+	
+	if err != nil {
+		http.Error(w, err.Error(), 500)
+		return
+	}
+}
+
 // A successful subscription payment should be confirmed by Stripe and
 // Updated through this hook.
 func invoicePaid(w http.ResponseWriter, db *sql.DB, r *http.Request) {
@@ -3698,6 +3760,10 @@ func api(w http.ResponseWriter, r *http.Request) {
 		r.Method == http.MethodPost &&
 		guard(r, 1):
 		subscribe(w, db, r)
+	case match(p, "/api/user/unsubscribe", &args) &&
+		r.Method == http.MethodGet &&
+		guard(r, 1):
+		unsubscribe(w, db, r)
 	case match(p, "/api/user/trial", &args) &&
 		r.Method == http.MethodPost &&
 		guard(r, 1):
@@ -3813,7 +3879,7 @@ func serve() {
 }
 
 func dbReset(db *sql.DB) {
-	b, err := os.ReadFile("migrations/reset.sql")
+	b, err := migrations.ReadFile("migrations/reset.sql")
 	if err != nil {
 		log.Fatal(err)
 	}
@@ -3823,7 +3889,7 @@ func dbReset(db *sql.DB) {
 		log.Fatal(err)
 	}
 
-	b, err = os.ReadFile("migrations/0_29092022_setup_tables.sql")
+	b, err = migrations.ReadFile("migrations/0_29092022_setup_tables.sql")
 	if err != nil {
 		log.Fatal(err)
 	}
@@ -4135,7 +4201,7 @@ func dev(args []string) {
 }
 
 func check(args []string) {
-	files := http.FileServer(http.Dir(""))
+	files := http.FileServerFS(assets)
 
 	http.Handle("/assets/", files)
 	http.HandleFunc("/", checkPdf)