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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,13 @@ EXPO_ACCESS_TOKEN=
DEVICE_RETENTION_DAYS=90
DEVICE_PURGE_CRON=0 6 * * *

# --- Verified badge ---
# The nightly repair for billing state that drifted. It is also the only thing
# that notices a ban - those are applied by hand in SQL - so the promise that a
# suspended account stops being charged rests on this running.
SUBSCRIPTION_RECONCILE_CRON=0 3 * * *
SUBSCRIPTION_RECONCILE_BATCH_SIZE=500

# --- Mobile clients ---
# How long after a rotation a retired refresh token is still accepted as a
# retry rather than treated as a stolen one. Mobile clients lose the *response*
Expand Down
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,19 @@ Copy lives in `push-copy.ts` (tr/en), chosen from the **device's** locale rather
Dead tokens go two ways: Expo reports `DeviceNotRegistered` in a ticket and those rows are deleted at once, while a phone that was simply abandoned is caught by `DEVICE_PURGE_CRON` against `lastSeenAt` (the app re-registers at every launch, so age means something here). `PUSH_ENABLED=false` swaps in `NoopPushService` — devices still register, nothing is delivered.

`docs/push-notifications.md` is the client-facing contract.
### Verified badge

A monthly paid subscription, and the only way to get a tick — no official badge, no manual grant. This half is provider-agnostic; the store adapter lands separately.

**`User.verifiedUntil` is a date, denormalised onto the user row.** Denormalised because roughly a dozen queries show an author and none should join a billing table to render a tick; a *date* because that makes it expire on its own — a lost provider notification then costs the badge at the end of the period the user paid for, where a boolean would leave it on for good. `isVerified` is computed at read time by the shared `isVerified()` helper, and only `SyncSubscriptionUseCase` ever writes the column, from `Subscription.entitlementUntil()` — the single place a billing state becomes a badge. `ACTIVE` and `IN_GRACE` entitle (the user paid for the period they are in; a declined card is not a decision to stop paying), everything else does not.

**One door in.** Every adapter reaches `SyncSubscriptionUseCase` with the provider's *absolute* state rather than a change, so a redelivered notification is harmless. It refuses two things: a `providerSubscriptionId` already claimed by another account (unique column — a shared receipt must not grant the badge to whoever presents it last) and an event older than `lastEventAt` (store notifications are unordered, and a late renewal would otherwise reinstate a cancelled subscription). `BillingEvent` is an audit trail, not the replay guard.

**Ban and deletion stop the billing.** `SoftDeleteUserUseCase` revokes immediately — awaited, not fire-and-forget, because it is a promise about somebody's money. A ban has no code path to hook, so the nightly `SubscriptionReconcileScheduler` is the only thing that notices one; it also retries refused cancellations and re-applies what the provider says, repairing missed notifications. A provider that cannot say is left alone: "I do not know this subscription" is not "it ended".

`NoopBillingService` stands in until there is a store, and deliberately never reports a subscription as active — a stub that granted entitlements would be free badges on any misconfigured environment.

`docs/verified-badge.md` is the contract and the operator's SQL.

### Realtime and background jobs

Expand Down
160 changes: 160 additions & 0 deletions docs/verified-badge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# Verified badge

A monthly paid subscription that puts a tick beside an account. There is no
other way to get one: no official badge, no manual grant, no notability
committee. Somebody pays, or there is no tick.

This document describes the part of the feature that is the same whichever
store or gateway is billing. The store adapter — purchases, notifications,
cancellations — is separate, and lands with Google Play Billing.

## What the client sees

Every read that shows a person carries `isVerified`: posts, comments, articles,
notifications, profiles, search results, follower lists, suggestions and
conversation participants. It is a boolean, computed at read time.

`GET /api/v1/billing/subscription` — authenticated, and only ever about the
caller:

```json
{
"data": {
"isVerified": true,
"verifiedUntil": "2026-12-01T00:00:00.000Z",
"status": "ACTIVE",
"currentPeriodEnd": "2026-12-01T00:00:00.000Z",
"cancelAtPeriodEnd": false
},
"meta": { "timestamp": "…" }
}
```

An account that has never subscribed gets `status: null` and the rest empty.
There is no endpoint that asks about somebody else — whether a person pays is
not other people's business, and the part that *is* public already travels on
every profile.

No receipts, amounts or payment methods appear anywhere. The store keeps those
and shows them to the user itself; copying them would mean owning a second,
permanently stale ledger.

## How the badge is decided

`users.verifiedUntil` is a **date**, denormalised onto the user row.

It is denormalised because roughly a dozen queries show an author and none of
them should join a billing table to render a tick. It is a date rather than a
boolean because that makes it **expire on its own**: if a provider notification
is lost, the badge disappears at the end of the period the user paid for. A
boolean would stay on for good, and the failure would be invisible.

Only `SyncSubscriptionUseCase` writes it, from `Subscription.entitlementUntil()`
— the one place a billing state becomes a badge:

| Status | Badge |
| --- | --- |
| `ACTIVE` | until `currentPeriodEnd` |
| `IN_GRACE` | until `currentPeriodEnd` |
| `PENDING` | none — nothing has been paid yet |
| `CANCELED` | none |
| `REVOKED` | none |

`IN_GRACE` keeps the badge on purpose: the provider is still retrying a failed
payment, and the user has paid for the period they are in. Removing it the
moment a card is declined punishes an expired card rather than a decision to
stop paying.

## How state gets in

One door: `SyncSubscriptionUseCase`. Every adapter arrives there with the
provider's **absolute state** — what is true now — rather than a change to
apply. That is what makes a redelivered notification harmless: applying it
twice lands in the same place.

Two things it refuses:

- **A purchase already claimed by another account.** `provider_subscription_id`
is unique, and a subscription belonging to somebody else is refused rather
than moved. The alternative is a shared receipt granting 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.

`billing_events` records what was processed. It is an audit trail, not the
replay guard — the guard is that every write is absolute.

## Ban and deletion

The platform promises that a suspended or deleted account stops being charged.
Only the provider can keep that promise, so both paths ask it to and clear the
badge either way — a provider outage must not leave a banned account verified
for another day.

- **Deletion** is a code path: `SoftDeleteUserUseCase` revokes immediately,
before the confirmation email. Immediately rather than at period end, because
the account is going away regardless.
- **A ban is not.** It is applied by hand in SQL — there is no endpoint and no
admin panel — so nothing in the code hears about it. The nightly reconcile is
the only thing that will ever notice, and this promise rests on it running.

Recovering a soft-deleted account within the grace period does **not** bring the
subscription back; the user resubscribes. Say so in the deletion screen.

## The nightly reconcile

`SUBSCRIPTION_RECONCILE_CRON` (03:00 container time) does three things in one
pass over live subscriptions:

1. Revokes anything belonging to a banned or soft-deleted account.
2. Retries cancellations the provider refused earlier.
3. Re-reads the provider and re-applies what it says, repairing missed
notifications.

A provider that answers "I do not know this subscription" is left alone. That
is not the same as "it ended", and guessing between them is how a paying user
loses a badge; the expiry already on the row retires it if it really is over.

## Settings

| Variable | Default | What it does |
| --- | --- | --- |
| `SUBSCRIPTION_RECONCILE_CRON` | `0 3 * * *` | When the repair pass runs. |
| `SUBSCRIPTION_RECONCILE_BATCH_SIZE` | `500` | Rows examined per pass. |

There is no provider yet. `NoopBillingService` stands in, and it deliberately
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.

## Reading the state by hand

Who currently has a badge:

```sql
SELECT u.username, u."verifiedUntil", s.status, s.current_period_end
FROM users u
JOIN subscriptions s ON s.user_id = u.id
WHERE u."verifiedUntil" > now()
ORDER BY u."verifiedUntil";
```

Badges that are about to lapse, and whether the provider intends to renew:

```sql
SELECT u.username, s.status, s.cancel_at_period_end, s.current_period_end
FROM subscriptions s
JOIN users u ON u.id = s.user_id
WHERE s.current_period_end BETWEEN now() AND now() + interval '7 days'
ORDER BY s.current_period_end;
```

What a subscription has been told, most recent first:

```sql
SELECT type, processed_at
FROM billing_events
WHERE provider_subscription_id = '…'
ORDER BY processed_at DESC;
```
80 changes: 80 additions & 0 deletions prisma/migrations/20260912000000_add_subscriptions/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
-- Paid verification: one billing row per account, and the badge it grants.
--
-- "users.verifiedUntil" is denormalised on purpose. Every read that shows an
-- author shows the badge - roughly a dozen queries - and none of them should
-- join a billing table to render a tick. It is a date rather than a boolean so
-- that it expires 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, where a boolean would leave it on for good.
--
-- "subscriptions" is one row per user, never deleted while the account exists,
-- because a resubscription has to attach to the same provider customer instead
-- of creating a second one nobody can reconcile. What it deliberately does not
-- hold is invoices, receipts, amounts or payment methods: the store keeps all
-- of that and shows it to the user itself, and copying it would mean owning a
-- permanently stale second ledger.
--
-- "provider_subscription_id" is unique so one purchase cannot be claimed by two
-- accounts. "last_event_at" is what makes out-of-order notifications safe -
-- store events are not ordered, and a renewal delivered after the cancellation
-- that superseded it would otherwise reinstate a subscription that is over.
--
-- "billing_events" is an audit trail, not the replay guard: every sync writes
-- the state the provider reports rather than adjusting what is there, so
-- applying one twice lands in the same place either way.

-- CreateEnum
CREATE TYPE "public"."BillingProvider" AS ENUM ('GOOGLE_PLAY');

-- CreateEnum
CREATE TYPE "public"."SubscriptionStatus" AS ENUM ('PENDING', 'ACTIVE', 'IN_GRACE', 'CANCELED', 'REVOKED');

-- AlterTable
ALTER TABLE "public"."users" ADD COLUMN "verifiedUntil" TIMESTAMP(3);

-- CreateTable
CREATE TABLE "public"."subscriptions" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"provider" "public"."BillingProvider" NOT NULL,
"provider_customer_id" TEXT,
"provider_subscription_id" TEXT,
"status" "public"."SubscriptionStatus" NOT NULL,
"current_period_end" TIMESTAMP(3),
"cancel_at_period_end" BOOLEAN NOT NULL DEFAULT false,
"last_event_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,

CONSTRAINT "subscriptions_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "public"."billing_events" (
"id" TEXT NOT NULL,
"provider" "public"."BillingProvider" NOT NULL,
"type" TEXT NOT NULL,
"provider_subscription_id" TEXT,
"processed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "billing_events_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "subscriptions_user_id_key" ON "public"."subscriptions"("user_id");

-- CreateIndex
CREATE UNIQUE INDEX "subscriptions_provider_subscription_id_key" ON "public"."subscriptions"("provider_subscription_id");

-- CreateIndex
CREATE INDEX "subscriptions_status_idx" ON "public"."subscriptions"("status");

-- CreateIndex
CREATE INDEX "billing_events_provider_subscription_id_idx" ON "public"."billing_events"("provider_subscription_id");

-- CreateIndex
CREATE INDEX "billing_events_processed_at_idx" ON "public"."billing_events"("processed_at");

-- AddForeignKey
ALTER TABLE "public"."subscriptions" ADD CONSTRAINT "subscriptions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

101 changes: 101 additions & 0 deletions prisma/models/subscription.prisma
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/// Which store or gateway a subscription is billed through.
///
/// One value today. The column exists so that adding the App Store, or a web
/// gateway, is a new value and a new adapter rather than a second table -
/// everything above this line is the same whichever one is paying.
enum BillingProvider {
GOOGLE_PLAY
}

/// Where a subscription stands, in the provider's terms.
///
/// Deliberately close to what the stores report rather than a tidier scheme of
/// our own: every value here arrives from outside, and a translation layer
/// would only give two vocabularies a chance to disagree.
enum SubscriptionStatus {
/// Purchase started, payment not yet confirmed.
PENDING
ACTIVE
/// Payment failed and the provider is retrying. The badge stays on until it
/// gives up - the user paid for the period they are in.
IN_GRACE
/// Provider gave up; the subscription is over unless the user resubscribes.
CANCELED
/// Ended because we cancelled it - a ban, or a deleted account.
REVOKED
}

/// One account's billing relationship, whether or not it currently pays.
///
/// A single row per user that is never deleted while the account exists. It
/// holds the provider's identifiers even after a subscription ends, because
/// resubscribing must attach to the same customer rather than creating a
/// second one that nobody can reconcile.
///
/// Note what is *not* here: invoices, receipts, amounts, payment methods. The
/// store keeps all of that and shows it to the user itself, and copying it
/// would mean owning a second, permanently stale ledger.
model Subscription {
id String @id @default(uuid())

userId String @unique @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)

provider BillingProvider

/// The provider's identifier for the payer, kept across resubscriptions.
providerCustomerId String? @map("provider_customer_id")

/// The provider's identifier for the current subscription. Unique so that
/// one purchase cannot be claimed by two accounts.
providerSubscriptionId String? @unique @map("provider_subscription_id")

status SubscriptionStatus

/// When the paid period ends. The badge is derived from this, not from
/// `status` - see `User.verifiedUntil`.
currentPeriodEnd DateTime? @map("current_period_end")

/// Set when the user has cancelled but the period they paid for is still
/// running.
cancelAtPeriodEnd Boolean @default(false) @map("cancel_at_period_end")

/// Timestamp of the provider event this row was last written from.
///
/// Store notifications are not ordered. Without this, a renewal delivered
/// after the cancellation that superseded it would quietly reinstate a
/// subscription that is over.
lastEventAt DateTime? @map("last_event_at")

createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

@@index([status])
@@map("subscriptions")
}

/// One provider notification, recorded after it has been applied.
///
/// Not the mechanism that makes replays safe - every sync writes the state the
/// provider reports rather than adjusting what is already there, so applying
/// the same event twice lands in the same place. This is the audit trail:
/// which notifications arrived, when, and in what order, which is the first
/// thing anybody wants when a subscription is in the wrong state.
model BillingEvent {
/// The provider's own event identifier.
id String @id

provider BillingProvider

/// The provider's event type, as sent.
type String

/// Which subscription it concerned, when it named one.
providerSubscriptionId String? @map("provider_subscription_id")

processedAt DateTime @default(now()) @map("processed_at")

@@index([providerSubscriptionId])
@@index([processedAt])
@@map("billing_events")
}
Loading