feat(billing): tell the truth about Paystack payments that cannot renew - #38
Merged
Conversation
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.
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.
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.
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.
Contributor
Deploying hookdrop with
|
| Latest commit: |
69830d8
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://7a854081.hookdrop.pages.dev |
| Branch Preview URL: | https://feat-paystack-non-recurring.hookdrop.pages.dev |
Contributor
Deploying hookdrop-frontend with
|
| Latest commit: |
69830d8
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://9cdf9b6e.hookdrop-frontend.pages.dev |
| Branch Preview URL: | https://feat-paystack-non-recurring.hookdrop-frontend.pages.dev |
EOEboh
added a commit
that referenced
this pull request
Aug 3, 2026
…ew (#38) * feat(billing): record whether a Paystack payment can auto-renew 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. * fix(billing): make repeat verifications idempotent 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. * feat(billing): extend the period from the current expiry on renewal 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. * feat(ui): show when a subscription will not renew, and offer renewal 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The first real payment exposed a product gap. It was made by bank transfer; Paystack subscriptions need an authorization that can be charged again, and a transfer produces
reusable: false. So Paystack took ₦3,500 once and created no subscription at all:The billing code did the right thing with what it was given — granted a month,
status=active, setcurrent_period_end. But the billing page said "Renews 2 September", which is untrue: nothing will ever charge them, and on that date the expiry gate silently drops them to free.This makes the app honest about it and gives the customer a deliberate way to continue. It does not restrict checkout to card — that trades a correctness problem for a possible conversion problem in a market where bank transfer is heavily used.
What changed
Detection is free —
authorization.reusableis already in the verify response. It is stored asauto_renews, defaulting to1so 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.Webhooks carry the flag forward rather than setting it. A
charge.successpayload does not describe the authorization, so it must not overwrite a decision made where that information was available.The repair pass backfills the existing row. "Customer has no Paystack subscription" is now a result rather than a failure to skip — it is precisely the bank-transfer case. Dry-run against a copy of production:
Renewing stacks onto the time left rather than restarting from today, so someone who renews early does not silently lose days they already paid for. An expired period is not carried forward — that would backdate the renewal.
The UI stops lying: "Expires 2 September", a note explaining the payment method cannot be charged again, and a Renew button. Cancel is hidden for these subscriptions — there is nothing at Paystack to cancel, and the old path would have marked the row cancelled and logged that the provider was never notified.
The hazard this created, and the guard for it
VerifyPaystackis deliberately forgiving about the same user resubmitting the same reference — that is what stops a double submit locking a paying customer out. Harmless while the period was always recomputed asnow + interval. Once the period stacks, that same path grants another month on every replay — a refreshed tab would buy free time.Each verification now claims its reference in
billing_eventsundersha256("paystack-verify:" + reference); theUNIQUEindex is the guard. Keyed on the reference rather thanprovider_sub_id, which only remembers the most recent one and would let an older reference be replayed after a renewal.Sequenced so the guard lands before the stacking — no commit in this history has a replay that grants free time. Mutation-checked: removing the guard fails
TestVerifyPaystack_ReplayingAReferenceDoesNotApplyTwicewithcurrent_period_end moved … replaying a reference granted extra time.Testing
All four commits verified independently in a worktree: Go build, vet, tests, and the real
npm run build.New tests: non-reusable payment is granted but flagged non-recurring · card payment recurs · a webhook cannot flip the flag · replaying a reference does not apply twice · renewal stacks from the current expiry · renewal after expiry starts from today.
Deliberately out of scope
The expiry reminder email. The sender only knows how to send magic links and needs generalising, plus a ticker and a sent-flag. Until it exists, a customer who never opens the app between paying and expiring still finds out by losing access — the honest UI does not reach them. This is the next thing worth doing.
Also still open: restricting checkout channels to card, and whether direct debit is available on the account (Paystack's docs say subscriptions accept "card and direct debit authorizations", which would keep a bank-based option that does recur).
After merging
Deploy, then run
-repair-paystack(dry run first) to backfill the live row.