From 9bf900049674bbb8d99ccac341d7671eb67e516e Mon Sep 17 00:00:00 2001 From: EOEboh Date: Mon, 3 Aug 2026 14:29:59 +0100 Subject: [PATCH 1/4] feat(billing): record whether a Paystack payment can auto-renew MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first real payment was made by bank transfer. Paystack subscriptions need an authorization that can be charged again, and a transfer produces reusable=false — so Paystack took the plan amount once and created no subscription at all. Confirmed on the live account: the customer has 17 authorizations, every one bank and non-reusable, and there are zero subscriptions on the whole integration. The code did the right thing with what it was given: granted a month, wrote status=active, set current_period_end. But the customer believes they subscribed, and the period will simply lapse with nothing ever charging them again. The verify response already answers this — authorization.reusable — so nothing new has to be fetched. It is stored on the subscription as auto_renews, defaulting to 1 so Lemon Squeezy and card-paid Paystack rows are untouched. A non-reusable payment is NOT rejected. They paid for a period and they get it; what changes is that the row no longer claims it will renew. Webhooks carry the stored value forward rather than setting it. A charge.success payload does not describe the authorization, so it must not overwrite a decision made where that information was available. The repair pass now treats "customer has no Paystack subscription" as a result rather than a failure to skip: that is precisely the bank-transfer case, so it marks the row non-recurring. This is what backfills the one existing live row, which predates the column and would otherwise default to claiming it renews. --- internal/billing/paystack.go | 11 ++++ internal/handler/billing.go | 26 +++++++-- internal/handler/billing_test.go | 92 ++++++++++++++++++++++++++++++++ internal/models/request.go | 12 +++-- internal/store/store.go | 38 +++++++++---- repair.go | 21 ++++++++ 6 files changed, 183 insertions(+), 17 deletions(-) diff --git a/internal/billing/paystack.go b/internal/billing/paystack.go index 35d0d0f..fa43239 100644 --- a/internal/billing/paystack.go +++ b/internal/billing/paystack.go @@ -585,6 +585,13 @@ type PaystackTransaction struct { CardCountry string // PayerIP is the address Paystack saw the payment come from. PayerIP string + // Channel is how it was paid: card, bank_transfer, ussd and so on. + Channel string + // Reusable reports whether the authorization can be charged again. + // Paystack subscriptions need one that can — bank transfer produces + // reusable=false, so the plan is charged once and no subscription is + // created at all. + Reusable bool // Plan carries the plan code and, when Paystack told us, its amount. Plan PaystackPlanInfo } @@ -621,6 +628,8 @@ type paystackVerifyResponse struct { } `json:"customer"` Authorization struct { CountryCode string `json:"country_code"` + Channel string `json:"channel"` + Reusable bool `json:"reusable"` } `json:"authorization"` Plan json.RawMessage `json:"plan"` PlanObject json.RawMessage `json:"plan_object"` @@ -742,6 +751,8 @@ func (p *PaystackProvider) VerifyTransaction( CustomerCode: out.Data.Customer.CustomerCode, CardCountry: strings.ToUpper(strings.TrimSpace(out.Data.Authorization.CountryCode)), PayerIP: out.Data.IPAddress, + Channel: out.Data.Authorization.Channel, + Reusable: out.Data.Authorization.Reusable, Plan: planInfo, }, nil } diff --git a/internal/handler/billing.go b/internal/handler/billing.go index 12cc202..7afc479 100644 --- a/internal/handler/billing.go +++ b/internal/handler/billing.go @@ -392,9 +392,14 @@ func (h *BillingHandler) processWebhookEvent( // charge.success names no subscription, so renewals carry an empty // SubscriptionID. The upsert writes every column, so passing it through // would blank the stored subscription code and break cancellation. + // + // auto_renews is decided by VerifyPaystack and the repair pass, which see + // the authorization. A webhook does not, so it must never overwrite it. providerSubID := event.SubscriptionID - if providerSubID == "" { - if existing, err := h.Store.GetSubscription(userID); err == nil { + autoRenews := true + if existing, err := h.Store.GetSubscription(userID); err == nil { + autoRenews = existing.AutoRenews + if providerSubID == "" { providerSubID = existing.ProviderSubID } } @@ -411,6 +416,7 @@ func (h *BillingHandler) processWebhookEvent( Currency: event.Currency, Interval: event.Interval, CancelAtPeriodEnd: event.CancelAtEnd, + AutoRenews: autoRenews, CreatedAt: time.Now().UTC(), } @@ -533,8 +539,19 @@ func (h *BillingHandler) VerifyPaystack(w http.ResponseWriter, r *http.Request) // Same user re-verifying is idempotent: a double submit or a retried // handlePaystackSuccess must not lock a paying customer out. - log.Printf("VerifyPaystack: verified user=%s ref=%s plan=%s interval=%s amount=%d card_country=%s payer_ip=%s", - user.ID, body.Reference, tx.Plan.Code, interval, tx.Amount, tx.CardCountry, tx.PayerIP) + log.Printf("VerifyPaystack: verified user=%s ref=%s plan=%s interval=%s amount=%d card_country=%s channel=%s reusable=%t payer_ip=%s", + user.ID, body.Reference, tx.Plan.Code, interval, tx.Amount, + tx.CardCountry, tx.Channel, tx.Reusable, tx.PayerIP) + + // Paystack subscriptions require an authorization that can be charged + // again. A bank transfer cannot, so Paystack takes the plan amount once + // and creates no subscription — the customer has bought a single period. + // They keep what they paid for; the UI has to stop calling it a + // subscription. + if !tx.Reusable { + log.Printf("VerifyPaystack: %s payment is not reusable — user=%s gets one period and will NOT auto-renew", + tx.Channel, user.ID) + } // NGN pricing is substantially cheaper than USD, and the currency is // chosen client-side — localStorage hookdrop_currency is enough to pick @@ -576,6 +593,7 @@ func (h *BillingHandler) VerifyPaystack(w http.ResponseWriter, r *http.Request) Currency: "ngn", Interval: interval, CancelAtPeriodEnd: false, + AutoRenews: tx.Reusable, CreatedAt: now, } diff --git a/internal/handler/billing_test.go b/internal/handler/billing_test.go index 69cca78..25a8980 100644 --- a/internal/handler/billing_test.go +++ b/internal/handler/billing_test.go @@ -34,6 +34,22 @@ func verifyBody(status, email, planCode string, amount, planAmount int) string { return verifyBodyFrom(status, email, planCode, amount, planAmount, "NG") } +// verifyBodyReusable builds a response for a given payment channel. +// reusable=false is what a Paystack bank transfer produces: the plan is +// charged once and no subscription is created. +func verifyBodyReusable(planCode string, amount, planAmount int, channel string, reusable bool) string { + return fmt.Sprintf(`{ + "status": true, + "data": { + "id": 1, "status": "success", "amount": %d, "currency": "NGN", + "reference": "T_ref", "ip_address": "102.90.1.1", + "authorization": {"country_code": "NG", "channel": %q, "reusable": %t}, + "customer": {"customer_code": "CUS_1", "email": "buyer@example.com"}, + "plan": %q, "plan_object": {"plan_code": %q, "amount": %d} + } + }`, amount, channel, reusable, planCode, planCode, planAmount) +} + // verifyBodyFrom lets a test set the card's issuing country. func verifyBodyFrom(status, email, planCode string, amount, planAmount int, cardCountry string) string { return fmt.Sprintf(`{ @@ -773,3 +789,79 @@ func TestVerifyPaystack_ForeignCardOnNgnPricingIsAllowedNotBlocked(t *testing.T) t.Errorf("plan = %q, want pro", sub.Plan) } } + +// A bank transfer cannot be charged again, so Paystack creates no +// subscription. The customer still gets the period they paid for, but the row +// must record that it will not renew. +func TestVerifyPaystack_NonReusablePaymentIsGrantedButNotRecurring(t *testing.T) { + h, user := newBillingTestHandler(t, + verifyBodyReusable(testPlanMonthly, monthlyKobo, monthlyKobo, "bank_transfer", false)) + + rec := httptest.NewRecorder() + h.VerifyPaystack(rec, verifyRequest(user.ID, user.Email, + `{"reference":"T_ref","plan":"pro","interval":"month"}`)) + + if rec.Code != http.StatusOK { + t.Fatalf("got %d, want 200 — they paid, they get the period: %s", rec.Code, rec.Body.String()) + } + sub, _ := h.Store.GetSubscription(user.ID) + if sub.Plan != "pro" || sub.Status != "active" { + t.Errorf("plan/status = %s/%s, want pro/active", sub.Plan, sub.Status) + } + if sub.AutoRenews { + t.Error("auto_renews = true for a bank transfer — it cannot be charged again") + } +} + +func TestVerifyPaystack_CardPaymentRecurs(t *testing.T) { + h, user := newBillingTestHandler(t, + verifyBodyReusable(testPlanMonthly, monthlyKobo, monthlyKobo, "card", true)) + + rec := httptest.NewRecorder() + h.VerifyPaystack(rec, verifyRequest(user.ID, user.Email, + `{"reference":"T_ref","plan":"pro","interval":"month"}`)) + if rec.Code != http.StatusOK { + t.Fatalf("got %d, want 200: %s", rec.Code, rec.Body.String()) + } + sub, _ := h.Store.GetSubscription(user.ID) + if !sub.AutoRenews { + t.Error("auto_renews = false for a reusable card authorization") + } +} + +// A webhook cannot see the authorization, so it must never flip the flag. +func TestPaystackWebhookPreservesAutoRenews(t *testing.T) { + h, user := newPaystackWebhookHandler(t) + + period := time.Now().UTC().Add(10 * 24 * time.Hour).Truncate(time.Second) + if err := h.Store.UpsertSubscription(&models.Subscription{ + UserID: user.ID, + Plan: "pro", + Provider: "paystack", + ProviderCustomerID: "CUS_keep", + ProviderSubID: "SUB_keep", + Status: "active", + CurrentPeriodEnd: &period, + Currency: "ngn", + Interval: "month", + AutoRenews: false, // paid by transfer + CreatedAt: time.Now().UTC(), + }); err != nil { + t.Fatalf("seed: %v", err) + } + + payload := fmt.Sprintf(`{"event":"charge.success","data":{ + "reference":"T_later","amount":%d,"currency":"NGN","status":"success", + "paid_at":%q,"metadata":0, + "customer":{"customer_code":"CUS_keep","email":"ngn@example.com"}, + "plan":%q,"plan_object":{"plan_code":%q,"amount":%d,"interval":"monthly"}}}`, + monthlyKobo, time.Now().UTC().Format(time.RFC3339), testPlanMonthly, testPlanMonthly, monthlyKobo) + + if rec := postPaystackWebhook(t, h, payload); rec.Code != http.StatusOK { + t.Fatalf("got %d, want 200: %s", rec.Code, rec.Body.String()) + } + sub, _ := h.Store.GetSubscription(user.ID) + if sub.AutoRenews { + t.Error("a webhook flipped auto_renews to true — it cannot see the authorization") + } +} diff --git a/internal/models/request.go b/internal/models/request.go index b99031e..2c2c087 100644 --- a/internal/models/request.go +++ b/internal/models/request.go @@ -85,10 +85,14 @@ type Subscription struct { CurrentPeriodEnd *time.Time `json:"current_period_end"` TrialEnd *time.Time `json:"trial_end"` CancelAtPeriodEnd bool `json:"cancel_at_period_end"` - Currency string `json:"currency"` - Interval string `json:"interval"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + // AutoRenews is false when the payment method cannot be charged again — + // a Paystack bank transfer, for example. Such a subscription is a single + // paid period that will simply expire, and the UI must say so. + AutoRenews bool `json:"auto_renews"` + Currency string `json:"currency"` + Interval string `json:"interval"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // BillingEvent is one inbound provider webhook, recorded before it is diff --git a/internal/store/store.go b/internal/store/store.go index 2d0c890..c12b407 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -164,6 +164,7 @@ func (s *Store) migrate() error { current_period_end DATETIME, trial_end DATETIME, cancel_at_period_end INTEGER DEFAULT 0, + auto_renews INTEGER DEFAULT 1, -- 0 when the payment method cannot be charged again currency TEXT DEFAULT 'usd', interval TEXT DEFAULT 'month', -- month or year created_at DATETIME NOT NULL, @@ -225,6 +226,10 @@ func (s *Store) migrate() error { {"users", "billing_email", "TEXT"}, // billing_events gained these when webhook deduplication landed; // deployed databases already have the narrower table. + // Paystack bank transfers cannot be charged again, so those + // subscriptions expire rather than renew. Defaults to 1 so existing + // rows and every Lemon Squeezy subscription are unaffected. + {"subscriptions", "auto_renews", "INTEGER DEFAULT 1"}, {"billing_events", "event_key", "TEXT"}, {"billing_events", "event_at", "DATETIME"}, {"billing_events", "object_id", "TEXT"}, @@ -706,14 +711,14 @@ func (s *Store) GetSubscription(userID string) (*models.Subscription, error) { SELECT id, user_id, plan, COALESCE(provider,''), COALESCE(provider_customer_id,''), COALESCE(provider_sub_id,''), status, current_period_end, trial_end, - cancel_at_period_end, COALESCE(currency,'usd'), + cancel_at_period_end, COALESCE(auto_renews,1), COALESCE(currency,'usd'), COALESCE(interval,'month'), created_at, updated_at FROM subscriptions WHERE user_id = ?`, userID, ).Scan( &sub.ID, &sub.UserID, &sub.Plan, &sub.Provider, &sub.ProviderCustomerID, &sub.ProviderSubID, &sub.Status, &sub.CurrentPeriodEnd, &sub.TrialEnd, - &sub.CancelAtPeriodEnd, &sub.Currency, &sub.Interval, + &sub.CancelAtPeriodEnd, &sub.AutoRenews, &sub.Currency, &sub.Interval, &sub.CreatedAt, &sub.UpdatedAt, ) if err == sql.ErrNoRows { @@ -915,7 +920,7 @@ func (s *Store) ListPaystackSubscriptionsNeedingRepair() ([]*models.Subscription SELECT id, user_id, plan, COALESCE(provider,''), COALESCE(provider_customer_id,''), COALESCE(provider_sub_id,''), status, current_period_end, trial_end, - cancel_at_period_end, COALESCE(currency,'usd'), + cancel_at_period_end, COALESCE(auto_renews,1), COALESCE(currency,'usd'), COALESCE(interval,'month'), created_at, updated_at FROM subscriptions WHERE provider = 'paystack' @@ -933,7 +938,7 @@ func (s *Store) ListPaystackSubscriptionsNeedingRepair() ([]*models.Subscription &sub.ID, &sub.UserID, &sub.Plan, &sub.Provider, &sub.ProviderCustomerID, &sub.ProviderSubID, &sub.Status, &sub.CurrentPeriodEnd, &sub.TrialEnd, - &sub.CancelAtPeriodEnd, &sub.Currency, &sub.Interval, + &sub.CancelAtPeriodEnd, &sub.AutoRenews, &sub.Currency, &sub.Interval, &sub.CreatedAt, &sub.UpdatedAt, ); err != nil { return nil, err @@ -954,6 +959,7 @@ func (s *Store) RepairPaystackSubscription( SET provider_sub_id = ?, status = ?, current_period_end = ?, + auto_renews = 1, updated_at = ? WHERE id = ?`, subscriptionCode, status, periodEnd, time.Now().UTC(), id, @@ -961,6 +967,19 @@ func (s *Store) RepairPaystackSubscription( return err } +// MarkSubscriptionNonRecurring records that a subscription cannot renew. +// +// Used for Paystack rows whose customer has no subscription at all: the plan +// was charged once against an authorization that cannot be reused, so the +// period simply expires. +func (s *Store) MarkSubscriptionNonRecurring(id string) error { + _, err := s.db.Exec( + `UPDATE subscriptions SET auto_renews = 0, updated_at = ? WHERE id = ?`, + time.Now().UTC(), id, + ) + return err +} + // subscriptionBy fetches one subscription by an indexed provider column. // column is never caller-supplied — it comes from the two wrappers above. func (s *Store) subscriptionBy(column, value string) (*models.Subscription, error) { @@ -973,14 +992,14 @@ func (s *Store) subscriptionBy(column, value string) (*models.Subscription, erro SELECT id, user_id, plan, COALESCE(provider,''), COALESCE(provider_customer_id,''), COALESCE(provider_sub_id,''), status, current_period_end, trial_end, - cancel_at_period_end, COALESCE(currency,'usd'), + cancel_at_period_end, COALESCE(auto_renews,1), COALESCE(currency,'usd'), COALESCE(interval,'month'), created_at, updated_at FROM subscriptions WHERE `+column+` = ?`, value, ).Scan( &sub.ID, &sub.UserID, &sub.Plan, &sub.Provider, &sub.ProviderCustomerID, &sub.ProviderSubID, &sub.Status, &sub.CurrentPeriodEnd, &sub.TrialEnd, - &sub.CancelAtPeriodEnd, &sub.Currency, &sub.Interval, + &sub.CancelAtPeriodEnd, &sub.AutoRenews, &sub.Currency, &sub.Interval, &sub.CreatedAt, &sub.UpdatedAt, ) if err == sql.ErrNoRows { @@ -1006,8 +1025,8 @@ func (s *Store) UpsertSubscription(sub *models.Subscription) error { INSERT INTO subscriptions (id, user_id, plan, provider, provider_customer_id, provider_sub_id, status, current_period_end, trial_end, cancel_at_period_end, - currency, interval, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + auto_renews, currency, interval, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET plan = excluded.plan, provider = excluded.provider, @@ -1017,13 +1036,14 @@ func (s *Store) UpsertSubscription(sub *models.Subscription) error { current_period_end = excluded.current_period_end, trial_end = excluded.trial_end, cancel_at_period_end = excluded.cancel_at_period_end, + auto_renews = excluded.auto_renews, currency = excluded.currency, interval = excluded.interval, updated_at = excluded.updated_at`, sub.ID, sub.UserID, sub.Plan, sub.Provider, sub.ProviderCustomerID, sub.ProviderSubID, sub.Status, sub.CurrentPeriodEnd, sub.TrialEnd, - sub.CancelAtPeriodEnd, sub.Currency, sub.Interval, + sub.CancelAtPeriodEnd, sub.AutoRenews, sub.Currency, sub.Interval, sub.CreatedAt, sub.UpdatedAt, ) return err diff --git a/repair.go b/repair.go index a29647b..e68c444 100644 --- a/repair.go +++ b/repair.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log" + "strings" "time" "github.com/EOEboh/hookdrop/internal/billing" @@ -47,6 +48,26 @@ func repairPaystackSubscriptions( cancel() if err != nil { + // A customer with no subscription at Paystack is a result, not a + // failure: they paid with an authorization that cannot be reused + // (a bank transfer), so there is nothing to reconcile and the + // period simply expires. Record that rather than reporting it as + // unreconcilable. + if strings.Contains(err.Error(), "no subscriptions") { + log.Printf("repair: user=%s customer=%s has no Paystack subscription", + sub.UserID, sub.ProviderCustomerID) + log.Printf("repair: auto_renews %t -> false (paid once, will not renew)", + sub.AutoRenews) + if apply { + if err := st.MarkSubscriptionNonRecurring(sub.ID); err != nil { + log.Printf("repair: WRITE FAILED: %v", err) + failed++ + continue + } + repaired++ + } + continue + } // Report and continue: one unreconcilable customer must not stop // the rest of the pass. log.Printf("repair: user=%s customer=%s SKIPPED: %v", From 12a76b16c14ad654d68a0b3e81a843dda6acad0f Mon Sep 17 00:00:00 2001 From: EOEboh Date: Mon, 3 Aug 2026 14:31:47 +0100 Subject: [PATCH 2/4] fix(billing): make repeat verifications idempotent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VerifyPaystack is forgiving about the same user resubmitting the same reference — a double submit or a retried handlePaystackSuccess must not lock a paying customer out. That is correct, but it only reapplies the same result today because the period is always recomputed as now + one interval. Once the period is extended from whatever the customer has left, that same path grants another month on every replay. A refreshed tab would buy free time. Each verification now claims its reference by recording it in billing_events under sha256("paystack-verify:" + reference). The UNIQUE index on event_key is the guard: an already-applied reference returns the current subscription unchanged, still 200, without touching the period. Keyed on the reference rather than provider_sub_id because that column only remembers the most recent one, so an older reference could be replayed after a renewal. Landed before the change that extends the period, so no commit in this history has a replay that grants free time. --- internal/handler/billing.go | 42 ++++++++++++++++++++++++++++++-- internal/handler/billing_test.go | 36 +++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/internal/handler/billing.go b/internal/handler/billing.go index 7afc479..819096f 100644 --- a/internal/handler/billing.go +++ b/internal/handler/billing.go @@ -536,8 +536,46 @@ func (h *BillingHandler) VerifyPaystack(w http.ResponseWriter, r *http.Request) http.Error(w, "this payment has already been redeemed", http.StatusConflict) return } - // Same user re-verifying is idempotent: a double submit or a retried - // handlePaystackSuccess must not lock a paying customer out. + // Same user re-verifying must not fail — a double submit or a retried + // handlePaystackSuccess should not lock a paying customer out. But it must + // not be *applied* twice either: the period is extended from whatever is + // left, so replaying a reference would hand out free time. + // + // Recording the verification claims the reference. The UNIQUE index on + // event_key decides, which is stronger than comparing against + // provider_sub_id — that only remembers the most recent reference and + // would let an older one be replayed after a renewal. + claim := sha256.Sum256([]byte("paystack-verify:" + body.Reference)) + claimKey := hex.EncodeToString(claim[:]) + switch err := h.Store.RecordBillingEvent(&models.BillingEvent{ + UserID: user.ID, + Provider: "paystack", + EventType: "verify", + Payload: body.Reference, + EventKey: claimKey, + ObjectID: tx.CustomerCode, + }); { + case errors.Is(err, store.ErrDuplicateBillingEvent): + log.Printf("VerifyPaystack: reference %s already applied for user=%s — returning current state", + body.Reference, user.ID) + current, err := h.Store.GetSubscription(user.ID) + if err != nil { + http.Error(w, "store error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "plan": current.Plan, + "status": current.Status, + "is_trial": false, + "trial_end": nil, + }) + return + case err != nil: + log.Printf("VerifyPaystack: could not claim reference %s: %v", body.Reference, err) + http.Error(w, "store error", http.StatusInternalServerError) + return + } log.Printf("VerifyPaystack: verified user=%s ref=%s plan=%s interval=%s amount=%d card_country=%s channel=%s reusable=%t payer_ip=%s", user.ID, body.Reference, tx.Plan.Code, interval, tx.Amount, diff --git a/internal/handler/billing_test.go b/internal/handler/billing_test.go index 25a8980..bed327e 100644 --- a/internal/handler/billing_test.go +++ b/internal/handler/billing_test.go @@ -865,3 +865,39 @@ func TestPaystackWebhookPreservesAutoRenews(t *testing.T) { t.Error("a webhook flipped auto_renews to true — it cannot see the authorization") } } + +// Re-verifying the same reference must succeed but must not be applied twice. +// Once the period stacks, a replay would otherwise hand out free time. +func TestVerifyPaystack_ReplayingAReferenceDoesNotApplyTwice(t *testing.T) { + h, user := newBillingTestHandler(t, + verifyBodyReusable(testPlanMonthly, monthlyKobo, monthlyKobo, "card", true)) + + post := func() *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + h.VerifyPaystack(rec, verifyRequest(user.ID, user.Email, + `{"reference":"T_ref","plan":"pro","interval":"month"}`)) + return rec + } + + if rec := post(); rec.Code != http.StatusOK { + t.Fatalf("first: got %d, want 200: %s", rec.Code, rec.Body.String()) + } + first, _ := h.Store.GetSubscription(user.ID) + if first.CurrentPeriodEnd == nil { + t.Fatal("no period after the first verification") + } + firstEnd := *first.CurrentPeriodEnd + + for i := 2; i <= 4; i++ { + if rec := post(); rec.Code != http.StatusOK { + t.Fatalf("attempt %d: got %d, want 200 — a retry must not lock the customer out: %s", + i, rec.Code, rec.Body.String()) + } + } + + after, _ := h.Store.GetSubscription(user.ID) + if !after.CurrentPeriodEnd.Equal(firstEnd) { + t.Errorf("current_period_end moved from %v to %v — replaying a reference granted extra time", + firstEnd, after.CurrentPeriodEnd) + } +} From cba6f28d7518303209b44213323173ff38f753a5 Mon Sep 17 00:00:00 2001 From: EOEboh Date: Mon, 3 Aug 2026 14:32:49 +0100 Subject: [PATCH 3/4] feat(billing): extend the period from the current expiry on renewal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A subscription that cannot auto-renew is continued by paying again, so the renewal has to respect what the customer still has. Restarting the period from the payment date silently takes back the days they already paid for. The new period is stacked onto current_period_end when it is still in the future, and starts from now when it has already lapsed — carrying an expired date forward would backdate the renewal to a date already gone. This is what the preceding commit's replay guard exists for: without it, resubmitting a reference would now add a whole month rather than recomputing the same answer. --- internal/handler/billing.go | 14 ++++++- internal/handler/billing_test.go | 67 ++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/internal/handler/billing.go b/internal/handler/billing.go index 819096f..479dbe8 100644 --- a/internal/handler/billing.go +++ b/internal/handler/billing.go @@ -611,7 +611,19 @@ func (h *BillingHandler) VerifyPaystack(w http.ResponseWriter, r *http.Request) // charged and is active from now. trial_end stays nil; only the Lemon // Squeezy path produces trialing subscriptions. subStatus := "active" - pe := now.Add(billing.PaystackBillingPeriod(interval)) + + // Stack onto whatever is left rather than restarting from now. A + // non-recurring subscription is renewed by paying again, and someone who + // renews early should not silently lose the days they already had. + // An expired period is not carried forward — that would backdate the + // renewal to a date already passed. + base := now + if current, err := h.Store.GetSubscription(user.ID); err == nil && + current.CurrentPeriodEnd != nil && + current.CurrentPeriodEnd.After(now) { + base = *current.CurrentPeriodEnd + } + pe := base.Add(billing.PaystackBillingPeriod(interval)) periodEnd := &pe customerCode := tx.CustomerCode diff --git a/internal/handler/billing_test.go b/internal/handler/billing_test.go index bed327e..4a86c01 100644 --- a/internal/handler/billing_test.go +++ b/internal/handler/billing_test.go @@ -901,3 +901,70 @@ func TestVerifyPaystack_ReplayingAReferenceDoesNotApplyTwice(t *testing.T) { firstEnd, after.CurrentPeriodEnd) } } + +// Renewing before expiry stacks onto the remaining time. Restarting from now +// would silently take back days the customer had already paid for. +func TestVerifyPaystack_RenewalExtendsFromTheCurrentExpiry(t *testing.T) { + h, user := newBillingTestHandler(t, + verifyBodyReusable(testPlanMonthly, monthlyKobo, monthlyKobo, "bank_transfer", false)) + + // 20 days still to run. + existing := time.Now().UTC().Add(20 * 24 * time.Hour).Truncate(time.Second) + if err := h.Store.UpsertSubscription(&models.Subscription{ + UserID: user.ID, Plan: "pro", Provider: "paystack", + ProviderCustomerID: "CUS_1", ProviderSubID: "T_earlier", + Status: "active", CurrentPeriodEnd: &existing, + Currency: "ngn", Interval: "month", CreatedAt: time.Now().UTC(), + }); err != nil { + t.Fatalf("seed: %v", err) + } + + rec := httptest.NewRecorder() + h.VerifyPaystack(rec, verifyRequest(user.ID, user.Email, + `{"reference":"T_ref","plan":"pro","interval":"month"}`)) + if rec.Code != http.StatusOK { + t.Fatalf("got %d, want 200: %s", rec.Code, rec.Body.String()) + } + + sub, _ := h.Store.GetSubscription(user.ID) + want := existing.Add(30 * 24 * time.Hour) + if sub.CurrentPeriodEnd == nil || sub.CurrentPeriodEnd.Sub(want).Abs() > time.Minute { + t.Errorf("current_period_end = %v, want ~%v (old expiry + one month)", + sub.CurrentPeriodEnd, want) + } + // Restarting from now would land ~20 days earlier. + if sub.CurrentPeriodEnd.Before(existing) { + t.Error("the renewal took back time the customer had already paid for") + } +} + +// An expired period must not be carried forward — that would backdate the +// renewal to a date already gone. +func TestVerifyPaystack_RenewalAfterExpiryStartsFromNow(t *testing.T) { + h, user := newBillingTestHandler(t, + verifyBodyReusable(testPlanMonthly, monthlyKobo, monthlyKobo, "bank_transfer", false)) + + lapsed := time.Now().UTC().Add(-10 * 24 * time.Hour).Truncate(time.Second) + if err := h.Store.UpsertSubscription(&models.Subscription{ + UserID: user.ID, Plan: "pro", Provider: "paystack", + ProviderCustomerID: "CUS_1", ProviderSubID: "T_earlier", + Status: "active", CurrentPeriodEnd: &lapsed, + Currency: "ngn", Interval: "month", CreatedAt: time.Now().UTC(), + }); err != nil { + t.Fatalf("seed: %v", err) + } + + rec := httptest.NewRecorder() + h.VerifyPaystack(rec, verifyRequest(user.ID, user.Email, + `{"reference":"T_ref","plan":"pro","interval":"month"}`)) + if rec.Code != http.StatusOK { + t.Fatalf("got %d, want 200: %s", rec.Code, rec.Body.String()) + } + + sub, _ := h.Store.GetSubscription(user.ID) + want := time.Now().UTC().Add(30 * 24 * time.Hour) + if sub.CurrentPeriodEnd.Sub(want).Abs() > time.Minute { + t.Errorf("current_period_end = %v, want ~%v (a full period from today)", + sub.CurrentPeriodEnd, want) + } +} From 69830d871ab56b855977f4a7f084683dc52f443d Mon Sep 17 00:00:00 2001 From: EOEboh Date: Mon, 3 Aug 2026 14:38:27 +0100 Subject: [PATCH 4/4] feat(ui): show when a subscription will not renew, and offer renewal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Paystack bank transfer buys a single period. The billing page said "Renews 2 September", which was simply untrue — nothing would charge them and access would end that day without warning. It now reads "Expires 2 September", with a note explaining that the payment method cannot be charged again, and a Renew button beside it. The Pro view previously had no payment control at all, so the existing PaystackButton is reused there with a label; renewing stacks onto the time already paid for rather than restarting from today. The manage panel hides Cancel for these subscriptions. There is nothing at Paystack to cancel, and the existing path would have marked the row cancelled and logged that the provider was never notified — noise describing a state that does not exist. It shows the expiry instead. --- .../billing/ManageSubscriptionPanel.tsx | 20 ++++++++- ui/src/components/billing/PricingPage.tsx | 43 ++++++++++++++++++- ui/src/types/index.ts | 3 ++ 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/ui/src/components/billing/ManageSubscriptionPanel.tsx b/ui/src/components/billing/ManageSubscriptionPanel.tsx index 00614cf..d84c0d5 100644 --- a/ui/src/components/billing/ManageSubscriptionPanel.tsx +++ b/ui/src/components/billing/ManageSubscriptionPanel.tsx @@ -10,6 +10,12 @@ export function ManageSubscriptionPanel({ }) { const posthog = usePostHog() const { subscription, isTrialing, refetch } = useBilling() + + // A subscription paid for with a method that cannot be charged again has + // nothing to cancel — Paystack holds no subscription for it. Offering the + // button would mark the row cancelled for no reason and log that the + // provider was never notified. + const willNotRenew = subscription?.auto_renews === false const [cancelling, setCancelling] = useState(false) const [cancelled, setCancelled] = useState(false) const [error, setError] = useState(null) @@ -94,6 +100,8 @@ export function ManageSubscriptionPanel({ ? 'Access until' : isTrialing ? 'Trial ends' + : willNotRenew + ? 'Expires' : 'Next billing date' } @@ -108,8 +116,16 @@ export function ManageSubscriptionPanel({ )} - {/* Cancel — only shown if not already cancelled */} - {!subscription?.cancel_at_period_end && ( + {willNotRenew && ( +

+ This is a one-off payment, so there is nothing to cancel — access + simply ends on {renewalDate}. Renew from the billing page to extend + it. +

+ )} + + {/* Cancel — hidden once cancelled, and for one-off payments */} + {!subscription?.cancel_at_period_end && !willNotRenew && (
) @@ -173,7 +177,12 @@ export function PricingPage() { setPayLoading(false) } - // ── Renewal date label — aware of cancellation and trial states + // A subscription paid for with a method that cannot be charged again is a + // single period, not a recurring plan. Saying "Renews" would be untrue. + const willNotRenew = + subscription?.auto_renews === false && !subscription?.cancel_at_period_end + + // ── Renewal date label — aware of cancellation, trial and one-off states function renewalLabel(): string { if (!subscription?.current_period_end) return 'monthly' const date = new Date(subscription.current_period_end).toLocaleDateString( @@ -181,6 +190,7 @@ export function PricingPage() { { day: 'numeric', month: 'long', year: 'numeric' }, ) if (subscription.cancel_at_period_end) return `Access until ${date}` + if (willNotRenew) return `Expires ${date}` return `Renews ${date}` } @@ -229,6 +239,35 @@ export function PricingPage() { ))}
+ {willNotRenew && ( +
+

+ This payment covered a single period and will not renew + automatically — the method you paid with cannot be charged + again. Renew below to extend your access; the new period is + added to the time you have left. +

+ +
+ )} + + {payError && ( +
+ {payError} +
+ )} + {/* Management section: toggles between button and panel */}
{showManagePanel ? ( diff --git a/ui/src/types/index.ts b/ui/src/types/index.ts index 93cf753..5cbb30e 100644 --- a/ui/src/types/index.ts +++ b/ui/src/types/index.ts @@ -82,6 +82,9 @@ export interface Subscription { current_period_end: string | null trial_end: string | null cancel_at_period_end: boolean + // False when the payment method cannot be charged again (a Paystack bank + // transfer). The subscription is a single paid period that will expire. + auto_renews: boolean currency: 'usd' | 'ngn' interval: 'month' | 'year' }