Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions internal/billing/paystack.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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
}
Expand Down
82 changes: 75 additions & 7 deletions internal/handler/billing.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand All @@ -411,6 +416,7 @@ func (h *BillingHandler) processWebhookEvent(
Currency: event.Currency,
Interval: event.Interval,
CancelAtPeriodEnd: event.CancelAtEnd,
AutoRenews: autoRenews,
CreatedAt: time.Now().UTC(),
}

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
}

Expand Down
195 changes: 195 additions & 0 deletions internal/handler/billing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(`{
Expand Down Expand Up @@ -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)
}
}
12 changes: 8 additions & 4 deletions internal/models/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading