feat(billing): add the paid verification badge, without a provider - #280
Merged
Conversation
Everything the badge needs that is the same whichever store is billing. The store adapter - purchases, notifications, cancellations - lands separately. users.verifiedUntil is a date rather than a boolean so the badge expires on its own: a lost provider notification then costs it at the end of the period the user paid for, where a boolean would leave it on for good. It is denormalised because a dozen queries show an author and none of them should join a billing table to render a tick. Subscription.entitlementUntil is the single place a billing state becomes a badge, and SyncSubscriptionUseCase the single door state enters through - always the provider's absolute state, never a change, so a redelivered notification is harmless. It refuses a purchase already claimed by another account, and an event older than the one already applied: store notifications are unordered, and a late renewal would otherwise reinstate a subscription that has ended. Ban and deletion stop the billing. Deletion is a code path and revokes immediately; a ban is applied by hand in SQL and has none, so the nightly reconcile is the only thing that notices it. That pass also retries refused cancellations and repairs missed notifications, and leaves a row alone when the provider cannot say - that is not the same as saying it ended. NoopBillingService stands in until there is a store and never reports a subscription as active, so a misconfigured environment cannot hand out free badges.
The quote card, and the article list, build their author object field by field rather than passing one through. Both were missed when the badge was threaded into the read paths, and a required boolean the serialiser is not given is not an omission - it fails the response. Every endpoint carrying a quote card answered 500, which is what the e2e caught. The port summaries and use case outputs now declare the field too, so the next producer that forgets it fails to compile rather than at runtime.
aquie00t
force-pushed
the
feature/verified-badge
branch
from
September 6, 2026 08:32
aa845f1 to
d872582
Compare
github-actions Bot
pushed a commit
that referenced
this pull request
Sep 6, 2026
# [1.26.0](v1.25.0...v1.26.0) (2026-09-06) ### Features * **billing:** add the paid verification badge, without a provider ([#280](#280)) ([9b76741](9b76741))
|
🎉 This PR is included in version 1.26.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
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.
Closes #275. Independent of #279 — both branch from
main.Summary
Everything the paid badge needs that is the same whichever store is billing. The store adapter — purchases, store notifications, cancellations — is #276 and lands separately, so nothing here can take money and nothing here needs a Play Console.
users.verifiedUntilis a date, not a boolean. Denormalised onto the user row because roughly a dozen queries show an author and none of them should join a billing table to render a tick — and a date because that makes the badge expire on its own. A provider notification that never arrives then costs the badge at the end of the period the user paid for, which is a failure everybody can live with; a boolean would leave it on for good and nobody would ever see the bug.One rule, one door.
Subscription.entitlementUntil()is the single place a billing state becomes a badge (ACTIVEandIN_GRACEentitle — the user paid for the period they are in, and a declined card is not a decision to stop paying), andSyncSubscriptionUseCaseis the only thing that writes the column. Every adapter arrives there with the provider's absolute state rather than a change, which is what makes a redelivered notification harmless.It refuses two things:
provider_subscription_idis unique and the sync refuses rather than moves it — otherwise a shared receipt grants the badge to whoever presents it last.last_event_ata renewal delivered after the cancellation that superseded it would reinstate a subscription that is over.Ban and deletion stop the billing, which is the promise made when this feature was scoped. Deletion is a code path, so
SoftDeleteUserUseCaserevokes immediately — awaited rather than fire-and-forget, because it is a promise about somebody's money. A ban is applied by hand in SQL and has no code path at all, so the nightly reconcile is the only thing that will ever notice one; the promise rests on that schedule running. Both clear the badge whether or not the provider answers, since an outage must not leave a suspended account verified for another day.The badge now travels on every read that shows a person: posts and quoted posts, comments, articles, notifications, profiles, search, follower and blocked lists, suggestions, bot directory and conversation participants — 13 selects, 7 mappers, 11 response schemas. Mention chips are deliberately left as
{ id, username }, which is the shapedocs/mentions.mdpromises.Root cause
There was no way to sell anything, and no way to show that somebody had bought it.
Notable decisions
NoopBillingServicenever reports a subscription as active. A stub that granted entitlements would be a way to get a paid badge for free on any environment that forgot to configure a store.Tests
SyncSubscriptionUseCasegranting, clearing, refusing a claimed purchase, refusing a stale event and preserving the provider customer id;RevokeSubscriptionUseCasecancelling at the provider, still revoking locally when the provider refuses, and doing nothing twice; the reconcile revoking banned and deleted accounts, re-applying provider state, leaving an unknown subscription alone and carrying on after one row throws; plus theisVerifiedhelper.SoftDeleteUserUseCasegained a case asserting the account stops being charged.tsc -p tsconfig.build.json --noEmit,eslint,prettier --checkclean.Rollout
Migration
20260912000000_add_subscriptionsadds two tables, two enums and one nullable column, with a cascade on the user. Both new settings have defaults, so the service boots unchanged; with no provider configured every account simply has no subscription and no badge.AI Asistan: Opus 5