From cb56814ba35c0928a43c0826dea3b6280ecf7c57 Mon Sep 17 00:00:00 2001 From: Martin Tournoij Date: Fri, 3 Jul 2020 06:02:28 +0800 Subject: [PATCH] Track billing amount, add form to set GitHub sponsors --- admin.go | 23 +- db/migrate/pgsql/2020-07-03-1-plan-amount.sql | 5 + .../sqlite/2020-07-03-1-plan-amount.sql | 5 + handlers/admin.go | 77 ++- handlers/billing.go | 15 +- pack/pack.go | 560 ++++++++++-------- site.go | 29 +- tpl/{backend_admin.gohtml => admin.gohtml} | 11 +- ...dmin_botlog.gohtml => admin_botlog.gohtml} | 0 tpl/admin_site.gohtml | 48 ++ ...kend_admin_sql.gohtml => admin_sql.gohtml} | 0 tpl/backend_admin_site.gohtml | 15 - user.go | 18 +- 13 files changed, 485 insertions(+), 321 deletions(-) create mode 100644 db/migrate/pgsql/2020-07-03-1-plan-amount.sql create mode 100644 db/migrate/sqlite/2020-07-03-1-plan-amount.sql rename tpl/{backend_admin.gohtml => admin.gohtml} (87%) rename tpl/{backend_admin_botlog.gohtml => admin_botlog.gohtml} (100%) create mode 100644 tpl/admin_site.gohtml rename tpl/{backend_admin_sql.gohtml => admin_sql.gohtml} (100%) delete mode 100644 tpl/backend_admin_site.gohtml diff --git a/admin.go b/admin.go index 81d7e00f6..e2d793dc7 100644 --- a/admin.go +++ b/admin.go @@ -24,15 +24,17 @@ import ( ) type AdminStat struct { - ID int64 `db:"id"` - Parent *int64 `db:"parent"` - Code string `db:"code"` - Stripe *string `db:"stripe"` - LinkDomain string `db:"link_domain"` - CreatedAt time.Time `db:"created_at"` - Plan string `db:"plan"` - LastMonth int `db:"last_month"` - Total int `db:"total"` + ID int64 `db:"id"` + Parent *int64 `db:"parent"` + Code string `db:"code"` + Stripe *string `db:"stripe"` + BillingAmount *string `db:"billing_amount"` + LinkDomain string `db:"link_domain"` + Email string `db:"email"` + CreatedAt time.Time `db:"created_at"` + Plan string `db:"plan"` + LastMonth int `db:"last_month"` + Total int `db:"total"` } type AdminStats []AdminStat @@ -45,6 +47,7 @@ func (a *AdminStats) List(ctx context.Context) error { sites.parent, sites.code, sites.created_at, + sites.billing_amount, (case when sites.stripe is null then 'free' when substr(sites.stripe, 0, 9) = 'cus_free' then 'free' @@ -52,6 +55,7 @@ func (a *AdminStats) List(ctx context.Context) error { end) as plan, stripe, sites.link_domain, + (select email from users where site=sites.id or site=sites.parent) as email, coalesce(( select sum(hit_counts.total) from hit_counts where site=sites.id ), 0) as total, @@ -60,7 +64,6 @@ func (a *AdminStats) List(ctx context.Context) error { where site=sites.id and hit_counts.hour >= %s ), 0) as last_month from sites - group by sites.id, sites.code, sites.created_at, plan order by last_month desc`, interval(30))) if err != nil { return errors.Wrap(err, "AdminStats.List") diff --git a/db/migrate/pgsql/2020-07-03-1-plan-amount.sql b/db/migrate/pgsql/2020-07-03-1-plan-amount.sql new file mode 100644 index 000000000..8c4d3826d --- /dev/null +++ b/db/migrate/pgsql/2020-07-03-1-plan-amount.sql @@ -0,0 +1,5 @@ +begin; + alter table sites add column billing_amount varchar; + + insert into version values('2020-07-03-1-plan-amount'); +commit; diff --git a/db/migrate/sqlite/2020-07-03-1-plan-amount.sql b/db/migrate/sqlite/2020-07-03-1-plan-amount.sql new file mode 100644 index 000000000..8c4d3826d --- /dev/null +++ b/db/migrate/sqlite/2020-07-03-1-plan-amount.sql @@ -0,0 +1,5 @@ +begin; + alter table sites add column billing_amount varchar; + + insert into version values('2020-07-03-1-plan-amount'); +commit; diff --git a/handlers/admin.go b/handlers/admin.go index f5339120f..148cae324 100644 --- a/handlers/admin.go +++ b/handlers/admin.go @@ -28,10 +28,11 @@ func (h admin) mount(r chi.Router) { //a := r.With(zhttp.Log(true, ""), keyAuth, adminOnly) a := r.With(zhttp.Log(true, ""), adminOnly) - a.Get("/admin", zhttp.Wrap(h.admin)) - a.Get("/admin/sql", zhttp.Wrap(h.adminSQL)) - a.Get("/admin/botlog", zhttp.Wrap(h.adminBotlog)) - a.Get("/admin/{id}", zhttp.Wrap(h.adminSite)) + a.Get("/admin", zhttp.Wrap(h.index)) + a.Get("/admin/sql", zhttp.Wrap(h.sql)) + a.Get("/admin/botlog", zhttp.Wrap(h.botlog)) + a.Get("/admin/{id}", zhttp.Wrap(h.site)) + a.Post("/admin/{id}/gh-sponsor", zhttp.Wrap(h.ghSponsor)) //aa.Get("/debug/pprof/*", pprof.Index) a.Get("/debug/*", func(w http.ResponseWriter, r *http.Request) { @@ -47,7 +48,7 @@ func (h admin) mount(r chi.Router) { a.Get("/debug/pprof/trace", pprof.Trace) } -func (h admin) admin(w http.ResponseWriter, r *http.Request) error { +func (h admin) index(w http.ResponseWriter, r *http.Request) error { if goatcounter.MustGetSite(r.Context()).ID != 1 { return guru.New(403, "yeah nah") } @@ -97,7 +98,7 @@ func (h admin) admin(w http.ResponseWriter, r *http.Request) error { l = l.Since("signups") l.FieldsSince().Debug("admin") - return zhttp.Template(w, "backend_admin.gohtml", struct { + return zhttp.Template(w, "admin.gohtml", struct { Globals Stats goatcounter.AdminStats Signups []goatcounter.Stat @@ -105,7 +106,7 @@ func (h admin) admin(w http.ResponseWriter, r *http.Request) error { }{newGlobals(w, r), a, signups, maxSignups}) } -func (h admin) adminSQL(w http.ResponseWriter, r *http.Request) error { +func (h admin) sql(w http.ResponseWriter, r *http.Request) error { if goatcounter.MustGetSite(r.Context()).ID != 1 { return guru.New(403, "yeah nah") } @@ -157,7 +158,7 @@ func (h admin) adminSQL(w http.ResponseWriter, r *http.Request) error { return err } - return zhttp.Template(w, "backend_admin_sql.gohtml", struct { + return zhttp.Template(w, "admin_sql.gohtml", struct { Globals Filter string Order string @@ -173,7 +174,7 @@ func (h admin) adminSQL(w http.ResponseWriter, r *http.Request) error { idx, prog}) } -func (h admin) adminBotlog(w http.ResponseWriter, r *http.Request) error { +func (h admin) botlog(w http.ResponseWriter, r *http.Request) error { if goatcounter.MustGetSite(r.Context()).ID != 1 { return guru.New(403, "yeah nah") } @@ -184,13 +185,13 @@ func (h admin) adminBotlog(w http.ResponseWriter, r *http.Request) error { return err } - return zhttp.Template(w, "backend_admin_botlog.gohtml", struct { + return zhttp.Template(w, "admin_botlog.gohtml", struct { Globals BotlogIP goatcounter.AdminBotlogIPs }{newGlobals(w, r), ips}) } -func (h admin) adminSite(w http.ResponseWriter, r *http.Request) error { +func (h admin) site(w http.ResponseWriter, r *http.Request) error { if goatcounter.MustGetSite(r.Context()).ID != 1 { return guru.New(403, "yeah nah") } @@ -216,8 +217,60 @@ func (h admin) adminSite(w http.ResponseWriter, r *http.Request) error { return err } - return zhttp.Template(w, "backend_admin_site.gohtml", struct { + return zhttp.Template(w, "admin_site.gohtml", struct { Globals Stat goatcounter.AdminSiteStat }{newGlobals(w, r), a}) } + +func (h admin) ghSponsor(w http.ResponseWriter, r *http.Request) error { + if goatcounter.MustGetSite(r.Context()).ID != 1 { + return guru.New(403, "yeah nah") + } + + v := zvalidate.New() + id := v.Integer("id", chi.URLParam(r, "id")) + + var args struct { + User string `json:"user"` + Amount string `json:"amount"` + Plan string `json:"plan"` + } + _, err := zhttp.Decode(r, &args) + if err != nil { + zhttp.FlashError(w, err.Error()) + return zhttp.SeeOther(w, fmt.Sprintf("/admin/%d", id)) + } + + v.Required("plan", args.Plan) + v.Include("plan", args.Plan, goatcounter.Plans) + if v.HasErrors() { + zhttp.FlashError(w, v.Error()) + return zhttp.SeeOther(w, fmt.Sprintf("/admin/%d", id)) + } + + var site goatcounter.Site + err = site.ByID(r.Context(), id) + if err != nil { + zhttp.FlashError(w, err.Error()) + return zhttp.SeeOther(w, fmt.Sprintf("/admin/%d", id)) + } + + if args.User != "" && !strings.HasPrefix(args.Amount, "USD ") { + args.Amount = "USD " + args.Amount + } + if args.User == "" { + args.User = fmt.Sprintf("cus_free_%d", site.ID) + } else if !strings.HasPrefix(args.User, "cus_github_") { + args.User = "cus_github_" + args.User + } + + ctx := goatcounter.WithSite(goatcounter.NewContext(r.Context()), &site) + err = site.UpdateStripe(ctx, args.User, args.Plan, args.Amount) + if err != nil { + zhttp.FlashError(w, err.Error()) + return zhttp.SeeOther(w, fmt.Sprintf("/admin/%d", id)) + } + + return zhttp.SeeOther(w, fmt.Sprintf("/admin/%d", id)) +} diff --git a/handlers/billing.go b/handlers/billing.go index 9140b4ec8..d77387890 100644 --- a/handlers/billing.go +++ b/handlers/billing.go @@ -186,7 +186,7 @@ func (h billing) start(w http.ResponseWriter, r *http.Request) error { if args.Plan == goatcounter.PlanPersonal && quantity == 0 { err := site.UpdateStripe(r.Context(), fmt.Sprintf("cus_free_%d", site.ID), - goatcounter.PlanPersonal) + goatcounter.PlanPersonal, "") if err != nil { return err } @@ -233,7 +233,7 @@ func (h billing) cancel(w http.ResponseWriter, r *http.Request) error { } err := zdb.TX(r.Context(), func(ctx context.Context, db zdb.DB) error { - err := site.UpdateStripe(r.Context(), *site.Stripe, goatcounter.PlanPersonal) + err := site.UpdateStripe(r.Context(), *site.Stripe, goatcounter.PlanPersonal, "") if err != nil { return err } @@ -278,7 +278,10 @@ type Session struct { ClientReferenceID string `json:"client_reference_id"` Customer string `json:"customer"` DisplayItems []struct { - Plan struct { + Amount int `json:"amount"` + Currency string `json:"currency"` + Quantity int `json:"quantity"` + Plan struct { Nickname string `json:"nickname"` } `json:"plan"` } `json:"display_items"` @@ -301,6 +304,8 @@ func (h billing) stripeWebhook(w http.ResponseWriter, r *http.Request) error { return err } + fmt.Println(string(event.Data.Raw)) + if strings.HasPrefix(s.ClientReferenceID, "one-time") { bgrun.Run(func() { t := "New one-time donation: " + s.ClientReferenceID @@ -328,7 +333,9 @@ func (h billing) stripeWebhook(w http.ResponseWriter, r *http.Request) error { return } - err = site.UpdateStripe(ctx, s.Customer, s.DisplayItems[0].Plan.Nickname) + amount := fmt.Sprintf("%s %d", strings.ToUpper(s.DisplayItems[0].Currency), + s.DisplayItems[0].Amount*s.DisplayItems[0].Quantity/100) + err = site.UpdateStripe(ctx, s.Customer, s.DisplayItems[0].Plan.Nickname, amount) if err != nil { l.Error(err) return diff --git a/pack/pack.go b/pack/pack.go index de00002b3..37363b1a1 100644 --- a/pack/pack.go +++ b/pack/pack.go @@ -651,6 +651,12 @@ commit; insert into version values('2020-06-26-1-record-export'); commit; +`), + "db/migrate/pgsql/2020-07-03-1-plan-amount.sql": []byte(`begin; + alter table sites add column billing_amount varchar; + + insert into version values('2020-07-03-1-plan-amount'); +commit; `), } @@ -1484,6 +1490,12 @@ commit; insert into version values('2020-06-26-1-record-export'); commit; +`), + "db/migrate/sqlite/2020-07-03-1-plan-amount.sql": []byte(`begin; + alter table sites add column billing_amount varchar; + + insert into version values('2020-07-03-1-plan-amount'); +commit; `), } @@ -15949,255 +15961,7 @@ Martin
{{- if .Flash}}
{{.Flash.Message}}
{{end -}} `), - "tpl/api.gohtml": []byte(`{{/************************************************************************* - * This file was generated from tpl/api.markdown. DO NOT EDIT. -*************************************************************************/}} - -{{template "_top.gohtml" .}} - -

GoatCounter API

-

GoatCounter has a rudimentary API; this is far from feature-complete, but solves -some common use cases.

- -

The API is currently unversioned and prefixed with /api/v0; while breaking -changes will be avoided and are not expected, they may occur. I'll be sure to -send ample notification of this to everyone who has generated an API key.

- -

Authentication

-

To use the API create a key in your account (Settings → Password, MFA, API); -send the API key in the Authorization header as Authorization: bearer -[token].

- -

You will need to use Content-Type: application/json; all requests return JSON -unless noted otherwise.

- -

Example:

- -
curl -X POST \
-    -H 'Content-Type: application/json' \
-    -H 'Authorization: Bearer 2q2snk7clgqs63tr4xc5bwseajlw88qzilr8fq157jz3qxwwmz5' \
-    https://example.goatcounter.com/api/v0/export
-
- -

Rate limit

-

The rate limit is 60 requests per 120 seconds. The current rate limits are -indicated in the X-Rate-Limit-Limit, X-Rate-Limit-Remaining, and -X-Rate-Limit-Reset headers.

- -

API reference

-

API reference docs are available at:

- - - -

The files are also available in the docs directory of the source repository.

- -

Examples

- -

Export

- -

Example to export via the API:

- -
token=[your api token]
-api=http://[my code].goatcounter.com/api/v0
-curl() {
-    \command curl --silent \
-        -H 'Content-Type: application/json' \
-        -H "Authorization: Bearer $token" \
-        $@
-}
-
-# Start a new export, get ID from response object.
-id=$(curl -X POST "$api/export" | jq .id)
-
-# The export is started in the background, so we'll need to wait until it's finished.
-while :; do
-    sleep 1
-
-    finished=$(curl "$api/export/$id" | jq .finished_at)
-    if [ "$finished" != "null" ]; then
-        # Download the export.
-        curl "$api/export/$id/download" | gzip -d
-
-        break
-    fi
-done
-
- -

The above doesn't does no error checking for brevity: errors are reported in the -error field as a string, or in the errors field as {"name": ["err1", -"err2", "name2": [..]}.

- -

The export object contains a last_hit_id parameter, which can be used as a -pagination cursor to only download hits after this export. This is useful to -sync your local database every hour or so:

- -
# Get cursor
-start=$(curl "$api/export/$id" | jq .last_hit_id)
-
-# Start new export starting from the cursor.
-id=$(curl -X POST --data "{\"start_from_hit_id\":$start}" "$api/export" | jq .id)
-
- -{{template "_bottom.gohtml" .}} -`), - "tpl/backend.gohtml": []byte(`{{- template "_backend_top.gohtml" . -}} - -{{if .User.ID}} - {{if not .User.EmailVerified}} -
- Please verify your email by clicking the link sent to {{.User.Email}}. - (Why?)
- - Change the email address in the settings – -
- . -
-
- {{end}} - - {{if not .Site.ReceivedData}} -
-

No data received – GoatCounter hasn’t received any - data yet.
- Make sure the site is set up correctly with the code below inserted in - your page, ideally just before the closing </body> tag (but - anywhere will work):

- {{template "_backend_sitecode.gohtml" .}} - -

This message will disappear once we receive data; see - Site code in the top menu for further - documentation and ready-made integrations.

-
- {{end}} -{{end}} {{/* .User.ID */}} - -
- {{/* The first button gets used on the enter key, AFAICT there is no way to change that. */}} - - {{if .ShowRefs}}{{end}} - - - {{/* -
- Saved views: 404 · blog - | Save current view -
- */}} -
-
- - –{{- "" -}} - {{- "" -}} - - - - Last - · - · - · - · - · - - - - - Current - · - - - -
- -
- - {{if .ForcedDaily}} - - {{else}} - - {{end}} -
-
-
-
- ← back - · - -
-
- · - - forward → -
-
-
- -
-

Paths

- - {{template "_backend_totals.gohtml" .}} - {{template "_backend_pages.gohtml" .}} -
- - Show more -
- -
-
-

Browsers

- {{if eq .TotalBrowsers 0}} - Nothing to display - {{else}} -
-
{{horizontal_chart .Context .Browsers .TotalBrowsers 0 .1 true true}}
-
- {{end}} -
-
-

Systems

- {{if eq .TotalSystems 0}} - Nothing to display - {{else}} -
-
{{horizontal_chart .Context .Systems .TotalSystems 0 .5 true true}}
-
- {{end}} -
- -
-

Screen size{{if before_size .Site.CreatedAt}}{{end}}

- {{if eq .TotalHits 0}} - Nothing to display - {{else}} -
-
{{horizontal_chart .Context .SizeStat .TotalSize 0 0 true false}}
-
-

The screen sizes are an indication and influenced by DPI and zoom levels.

- {{end}} -
-
-

Locations{{if before_loc .Site.CreatedAt}}{{end}}

- {{if eq .TotalHits 0}} - Nothing to display - {{else}} -
-
{{horizontal_chart .Context .LocationStat .TotalLocation 0 3 false true}}
-
- {{if .ShowMoreLocations}}Show all{{end}} - {{end}} -
-
- -{{- template "_backend_bottom.gohtml" . }} -`), - "tpl/backend_admin.gohtml": []byte(`{{template "_backend_top.gohtml" .}} + "tpl/admin.gohtml": []byte(`{{template "_backend_top.gohtml" .}}