Skip to content

feat(billing): tell the truth about Paystack payments that cannot renew - #38

Merged
EOEboh merged 4 commits into
mainfrom
feat/paystack-non-recurring
Aug 3, 2026
Merged

feat(billing): tell the truth about Paystack payments that cannot renew#38
EOEboh merged 4 commits into
mainfrom
feat/paystack-non-recurring

Conversation

@EOEboh

@EOEboh EOEboh commented Aug 3, 2026

Copy link
Copy Markdown
Owner

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:

transaction T981099203072787  channel=bank_transfer  authorization.reusable=false
customer CUS_uax80r6cuvpag0p  subscriptions: 0  authorizations: 17 (all bank, none reusable)
subscriptions on the whole live integration: 0

The billing code did the right thing with what it was given — granted a month, status=active, set current_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 freeauthorization.reusable is already in the verify response. It is stored 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.

Webhooks carry the flag 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 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:

repair: 1 Paystack row(s) need repair (DRY RUN — nothing will be written)
repair:   user=a453867a… customer=CUS_uax80r6cuvpag0p has no Paystack subscription
repair:     auto_renews        true -> false (paid once, will not renew)

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

VerifyPaystack is 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 as now + 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_events under sha256("paystack-verify:" + reference); the UNIQUE index is the guard. Keyed on the reference rather than provider_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_ReplayingAReferenceDoesNotApplyTwice with current_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.

EOEboh added 4 commits August 3, 2026 14:29
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.
@cloudflare-workers-and-pages

Copy link
Copy Markdown
Contributor

Deploying hookdrop with  Cloudflare Pages  Cloudflare Pages

Latest commit: 69830d8
Status: ✅  Deploy successful!
Preview URL: https://7a854081.hookdrop.pages.dev
Branch Preview URL: https://feat-paystack-non-recurring.hookdrop.pages.dev

View logs

@cloudflare-workers-and-pages

Copy link
Copy Markdown
Contributor

Deploying hookdrop-frontend with  Cloudflare Pages  Cloudflare Pages

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

View logs

@EOEboh
EOEboh merged commit 0b2bf00 into main Aug 3, 2026
6 checks passed
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.
@EOEboh
EOEboh deleted the feat/paystack-non-recurring branch August 3, 2026 22:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant