Skip to content

feat(billing): add the paid verification badge, without a provider - #280

Merged
aquie00t merged 2 commits into
mainfrom
feature/verified-badge
Sep 6, 2026
Merged

feat(billing): add the paid verification badge, without a provider#280
aquie00t merged 2 commits into
mainfrom
feature/verified-badge

Conversation

@aquie00t

@aquie00t aquie00t commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

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.verifiedUntil is 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 (ACTIVE and IN_GRACE entitle — the user paid for the period they are in, and a declined card is not a decision to stop paying), and SyncSubscriptionUseCase is 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:

  • A purchase already claimed by another account. provider_subscription_id is unique and the sync refuses rather than moves it — otherwise a shared receipt grants the badge to whoever presents it last.
  • An event older than the one already applied. Store notifications are not ordered; without last_event_at a 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 SoftDeleteUserUseCase revokes 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 shape docs/mentions.md promises.

Root cause

There was no way to sell anything, and no way to show that somebody had bought it.

Notable decisions

  • No receipts, amounts or payment methods are stored. The store keeps them and shows them to the user itself; a copy here would be a second, permanently stale ledger.
  • No endpoint asks about somebody else's subscription. Whether a person pays is not other people's business; the part that is public already travels on every profile.
  • NoopBillingService never 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.
  • A provider answering "I do not know this subscription" is left alone by the reconcile. That is not the same as "it ended", and guessing between them is how a paying user loses a badge.

Tests

  • Unit, 1487 passing (28 new): the entitlement rule for all five statuses and for a missing period; the out-of-order guard including a redelivery of the same event and an undated one; SyncSubscriptionUseCase granting, clearing, refusing a claimed purchase, refusing a stale event and preserving the provider customer id; RevokeSubscriptionUseCase cancelling 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 the isVerified helper.
  • Existing tests updated where an output legitimately grew a field, and SoftDeleteUserUseCase gained a case asserting the account stops being charged.
  • tsc -p tsconfig.build.json --noEmit, eslint, prettier --check clean.

Rollout

Migration 20260912000000_add_subscriptions adds 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

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
aquie00t force-pushed the feature/verified-badge branch from aa845f1 to d872582 Compare September 6, 2026 08:32
@aquie00t
aquie00t merged commit 9b76741 into main Sep 6, 2026
10 checks passed
@aquie00t
aquie00t deleted the feature/verified-badge branch September 6, 2026 08:37
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))
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown

🎉 This PR is included in version 1.26.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feature] Verified badge - provider-agnostic core

1 participant