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..479dbe8 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(), } @@ -530,11 +536,60 @@ 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, + tx.CardCountry, tx.Channel, tx.Reusable, tx.PayerIP) - 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) + // 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 @@ -556,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 @@ -576,6 +643,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..4a86c01 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,182 @@ 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") + } +} + +// 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) + } +} + +// 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) + } +} 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", 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' }