diff --git a/.env.example b/.env.example index 9c9b4bb8..88dbb59a 100644 --- a/.env.example +++ b/.env.example @@ -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* diff --git a/CLAUDE.md b/CLAUDE.md index 8153fc44..aa37c5d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/docs/verified-badge.md b/docs/verified-badge.md new file mode 100644 index 00000000..4f0e919d --- /dev/null +++ b/docs/verified-badge.md @@ -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; +``` diff --git a/prisma/migrations/20260912000000_add_subscriptions/migration.sql b/prisma/migrations/20260912000000_add_subscriptions/migration.sql new file mode 100644 index 00000000..49744016 --- /dev/null +++ b/prisma/migrations/20260912000000_add_subscriptions/migration.sql @@ -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; + diff --git a/prisma/models/subscription.prisma b/prisma/models/subscription.prisma new file mode 100644 index 00000000..b0860947 --- /dev/null +++ b/prisma/models/subscription.prisma @@ -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") +} diff --git a/prisma/models/user.prisma b/prisma/models/user.prisma index bdc72a50..ad6b1643 100644 --- a/prisma/models/user.prisma +++ b/prisma/models/user.prisma @@ -60,6 +60,22 @@ model User { /// rather than when the current access token expires. bannedAt DateTime? + /// When the paid verification badge expires, null for an account that has + /// never had one. + /// + /// Denormalised from the subscription because every read that shows an + /// author shows the badge - roughly a dozen queries - and none of them + /// should be joining a billing table to render a tick. + /// + /// A date rather than a boolean so that it expires on its own. A missed + /// provider notification then costs the badge at the end of the period the + /// user paid for, which is the failure everybody can live with; a boolean + /// would leave it on for good. + verifiedUntil DateTime? + + /// The billing relationship, if this account has ever had one. + subscription Subscription? + /// When the user opted out of the daily digest, null while subscribed. /// Written by the unsubscribe link, which carries a signed token and needs /// no session - a digest lands in an inbox long after one has expired. diff --git a/render.yaml b/render.yaml index 4fe8d7bf..073dc2e4 100644 --- a/render.yaml +++ b/render.yaml @@ -189,6 +189,13 @@ projects: sync: false - key: DEVICE_PURGE_CRON sync: false + # The paid verification badge. Both have defaults; they are declared + # because the reconcile pass is what notices a ban, and turning it off or + # slowing it down is a decision somebody should have to make on purpose. + - key: SUBSCRIPTION_RECONCILE_CRON + sync: false + - key: SUBSCRIPTION_RECONCILE_BATCH_SIZE + sync: false # Mobile clients. All four have defaults in env.schema.ts, so the service # boots without them; they are declared because the two build numbers are # what lets the API refuse a version that is too old to be talked to, and diff --git a/src/app.ts b/src/app.ts index f7b979b2..cc5593ad 100644 --- a/src/app.ts +++ b/src/app.ts @@ -24,6 +24,7 @@ import blockRoutes from "@routes/profile/block.routes"; import reportRoutes from "@routes/report.routes"; import metaRoutes from "@routes/meta.routes"; import deviceRoutes from "@routes/device.routes"; +import billingRoutes from "@routes/billing.routes"; import websocketPlugin from "./http/plugins/websocket.plugin"; import realtimeRoutes from "@routes/realtime.routes"; import notificationRoutes from "@routes/notification.routes"; @@ -32,6 +33,7 @@ import dailyDigestPlugin from "@plugins/custom/daily-digest.plugin"; import reportDigestPlugin from "@plugins/custom/report-digest.plugin"; import reportPurgePlugin from "@plugins/custom/report-purge.plugin"; import devicePurgePlugin from "@plugins/custom/device-purge.plugin"; +import subscriptionReconcilePlugin from "@plugins/custom/subscription-reconcile.plugin"; import userInterestRebuildPlugin from "@plugins/custom/user-interest-rebuild.plugin"; import mediaModerationPlugin from "@plugins/custom/media-moderation.plugin"; import messageRetentionPlugin from "@plugins/custom/message-retention.plugin"; @@ -123,6 +125,7 @@ export class App { this.server.register(reportDigestPlugin); this.server.register(reportPurgePlugin); this.server.register(devicePurgePlugin); + this.server.register(subscriptionReconcilePlugin); this.server.register(messageRetentionPlugin); } @@ -159,6 +162,7 @@ export class App { this.server.register(metaRoutes, { prefix: "/api/v1" }); this.server.register(deviceRoutes, { prefix: "/api/v1" }); + this.server.register(billingRoutes, { prefix: "/api/v1" }); this.server.register(realtimeRoutes, { prefix: "/api/v1/realtime" }); diff --git a/src/core/domain/entities/comment.entity.ts b/src/core/domain/entities/comment.entity.ts index 1f506347..90b08d35 100644 --- a/src/core/domain/entities/comment.entity.ts +++ b/src/core/domain/entities/comment.entity.ts @@ -239,6 +239,7 @@ export class Comment { username: string; avatarUrl?: string; fullName?: string; + isVerified?: boolean; } | undefined { return this.props.author; diff --git a/src/core/domain/entities/notification.entity.ts b/src/core/domain/entities/notification.entity.ts index 450abf0b..b872c3e6 100644 --- a/src/core/domain/entities/notification.entity.ts +++ b/src/core/domain/entities/notification.entity.ts @@ -115,6 +115,14 @@ export class Notification { return this.props.avatarUrl; } + /** + * Whether the issuer carries the paid verification badge + * @returns True while the badge is granted + */ + get isVerified(): boolean { + return this.props.isVerified ?? false; + } + /** * Get the reference ID of the notification (optional) * @returns The reference ID or undefined if not provided diff --git a/src/core/domain/entities/post.entity.ts b/src/core/domain/entities/post.entity.ts index 95a82116..0ae06aa4 100644 --- a/src/core/domain/entities/post.entity.ts +++ b/src/core/domain/entities/post.entity.ts @@ -138,6 +138,7 @@ export class Post { username?: string; avatarUrl?: string; fullName?: string; + isVerified?: boolean; } { return this.props.author; } diff --git a/src/core/domain/entities/profile.entity.ts b/src/core/domain/entities/profile.entity.ts index 995dbce3..bb1fdc70 100644 --- a/src/core/domain/entities/profile.entity.ts +++ b/src/core/domain/entities/profile.entity.ts @@ -145,6 +145,14 @@ export class Profile { return this.props.username; } + /** + * Whether the account carries the paid verification badge + * @returns True while the badge is granted + */ + get isVerified(): boolean { + return this.props.isVerified ?? false; + } + /** * Get the number of users following this profile * @returns The followers count diff --git a/src/core/domain/entities/subscription.entity.ts b/src/core/domain/entities/subscription.entity.ts new file mode 100644 index 00000000..05d20b59 --- /dev/null +++ b/src/core/domain/entities/subscription.entity.ts @@ -0,0 +1,128 @@ +import { SubscriptionStatus } from "@core/domain/enums"; +import type { BillingProvider } from "@core/domain/enums"; +import type { SubscriptionProps } from "@core/domain/interfaces/subscription-props.interface"; + +/** + * The statuses that entitle an account to the badge. + * + * `IN_GRACE` is in the list on purpose: the provider is still retrying a failed + * payment, and the period the user already paid for has not ended. Taking the + * badge away the moment a card is declined would punish an expired card rather + * than a decision to stop paying. + */ +const ENTITLING_STATUSES: ReadonlySet = new Set([ + SubscriptionStatus.ACTIVE, + SubscriptionStatus.IN_GRACE, +]); + +/** + * Rich domain model for one account's billing relationship. + * + * The row outlives any particular subscription: it holds the provider's + * identifiers so that resubscribing attaches to the same customer instead of + * creating a second one nobody can reconcile. + */ +export class Subscription { + private constructor(private readonly props: SubscriptionProps) {} + + /** + * Rebuilds an entity from a persisted row, or composes a new one. + * + * @param props - The stored shape + * @returns The Subscription instance it describes + */ + public static with(props: SubscriptionProps): Subscription { + return new Subscription(props); + } + + get id(): string { + return this.props.id!; + } + + get userId(): string { + return this.props.userId; + } + + get provider(): BillingProvider { + return this.props.provider; + } + + get providerCustomerId(): string | null { + return this.props.providerCustomerId ?? null; + } + + get providerSubscriptionId(): string | null { + return this.props.providerSubscriptionId ?? null; + } + + get status(): SubscriptionStatus { + return this.props.status; + } + + get currentPeriodEnd(): Date | null { + return this.props.currentPeriodEnd ?? null; + } + + get cancelAtPeriodEnd(): boolean { + return this.props.cancelAtPeriodEnd ?? false; + } + + get lastEventAt(): Date | null { + return this.props.lastEventAt ?? null; + } + + /** + * How long this subscription entitles its owner to the badge. + * + * The single place that turns a billing state into a badge. Everything + * else - reads, the reconcile job, the mappers - asks this rather than + * interpreting `status` for itself, which is what keeps "when does the + * tick disappear" from having several answers. + * + * A cancelled or revoked subscription entitles nothing, even if its period + * has not run out: cancellation here means the provider stopped, and + * revocation means we did. + * + * @returns When the badge expires, or null when there is nothing to grant + */ + public entitlementUntil(): Date | null { + if (!ENTITLING_STATUSES.has(this.props.status)) return null; + + return this.props.currentPeriodEnd ?? null; + } + + /** + * Whether a provider event should be applied to this row. + * + * Store notifications are not ordered, and each one carries the whole + * state rather than a change to it. Applying an older one after a newer + * one would not merely lose an update - it would reinstate a subscription + * that has since ended. + * + * An event with no timestamp is applied: a provider that does not date its + * notifications leaves nothing to compare, and refusing them all would + * mean never updating anything. + * + * @param eventAt - When the provider says the event happened + * @returns True when the event is newer than what this row was built from + */ + public accepts(eventAt: Date | null): boolean { + if (!eventAt) return true; + + const lastEventAt = this.props.lastEventAt; + + return !lastEventAt || eventAt.getTime() >= lastEventAt.getTime(); + } + + /** + * Whether the account is currently entitled to the badge. + * + * @param now - Reference time + * @returns True while the entitlement has not run out + */ + public isEntitled(now: Date = new Date()): boolean { + const until = this.entitlementUntil(); + + return until !== null && until.getTime() > now.getTime(); + } +} diff --git a/src/core/domain/enums/billing-provider.enum.ts b/src/core/domain/enums/billing-provider.enum.ts new file mode 100644 index 00000000..ced96591 --- /dev/null +++ b/src/core/domain/enums/billing-provider.enum.ts @@ -0,0 +1,12 @@ +/** + * Which store or gateway a subscription is billed through. + * + * One value today. It 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 the adapter is the same whichever one is paying. + * + * Mirrors the `BillingProvider` enum in the Prisma schema exactly. + */ +export enum BillingProvider { + GOOGLE_PLAY = "GOOGLE_PLAY", +} diff --git a/src/core/domain/enums/index.ts b/src/core/domain/enums/index.ts index 99249b23..5230f5f3 100644 --- a/src/core/domain/enums/index.ts +++ b/src/core/domain/enums/index.ts @@ -19,3 +19,5 @@ export { ReportTargetKind } from "./report-target-kind.enum"; export { ReportReason } from "./report-reason.enum"; export { ReportStatus } from "./report-status.enum"; export { DevicePlatform } from "./device-platform.enum"; +export { SubscriptionStatus } from "./subscription-status.enum"; +export { BillingProvider } from "./billing-provider.enum"; diff --git a/src/core/domain/enums/subscription-status.enum.ts b/src/core/domain/enums/subscription-status.enum.ts new file mode 100644 index 00000000..bce929a7 --- /dev/null +++ b/src/core/domain/enums/subscription-status.enum.ts @@ -0,0 +1,29 @@ +/** + * 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. + * + * Mirrors the `SubscriptionStatus` enum in the Prisma schema exactly. + */ +export enum SubscriptionStatus { + /** Purchase started, payment not yet confirmed. */ + PENDING = "PENDING", + + ACTIVE = "ACTIVE", + + /** + * Payment failed and the provider is retrying. + * + * The badge stays on: the user paid for the period they are in, and the + * provider has not given up on collecting the next one. + */ + IN_GRACE = "IN_GRACE", + + /** The provider gave up, or the user let it lapse. */ + CANCELED = "CANCELED", + + /** Ended because we cancelled it - a ban, or a deleted account. */ + REVOKED = "REVOKED", +} diff --git a/src/core/domain/interfaces/article-props.interface.ts b/src/core/domain/interfaces/article-props.interface.ts index e3d3fb14..4f59d30d 100644 --- a/src/core/domain/interfaces/article-props.interface.ts +++ b/src/core/domain/interfaces/article-props.interface.ts @@ -74,6 +74,9 @@ export interface ArticleProps { /** Indicates whether the author is the current authenticated user */ isMe?: boolean; + + /** Whether the author carries the paid verification badge */ + isVerified?: boolean; }; /** Tag names attached to the article, supplied explicitly by the author */ diff --git a/src/core/domain/interfaces/comment-props.interface.ts b/src/core/domain/interfaces/comment-props.interface.ts index fddee0c5..ebcd0302 100644 --- a/src/core/domain/interfaces/comment-props.interface.ts +++ b/src/core/domain/interfaces/comment-props.interface.ts @@ -67,6 +67,9 @@ export interface CommentProps { avatarUrl?: string; /** Full name of the author */ fullName?: string; + + /** Whether the author carries the paid verification badge */ + isVerified?: boolean; }; /** diff --git a/src/core/domain/interfaces/conversation-props.interface.ts b/src/core/domain/interfaces/conversation-props.interface.ts index b1fc3256..dc547e2b 100644 --- a/src/core/domain/interfaces/conversation-props.interface.ts +++ b/src/core/domain/interfaces/conversation-props.interface.ts @@ -19,6 +19,9 @@ export interface ConversationParticipant { /** Their stored avatar key or URL, when they have one */ avatarUrl?: string; + + /** Whether they carry the paid verification badge */ + isVerified?: boolean; } /** diff --git a/src/core/domain/interfaces/notification-props.interface.ts b/src/core/domain/interfaces/notification-props.interface.ts index d08353b3..f0346092 100644 --- a/src/core/domain/interfaces/notification-props.interface.ts +++ b/src/core/domain/interfaces/notification-props.interface.ts @@ -41,6 +41,9 @@ export interface NotificationProps { /** Optional avatar URL of the issuer for display purposes */ avatarUrl?: string; + /** Whether the issuer carries the paid verification badge */ + isVerified?: boolean; + /** Optional creation timestamp, defaults to current time if not provided */ createdAt?: Date; diff --git a/src/core/domain/interfaces/post-props.interface.ts b/src/core/domain/interfaces/post-props.interface.ts index c7d555e4..da511ddd 100644 --- a/src/core/domain/interfaces/post-props.interface.ts +++ b/src/core/domain/interfaces/post-props.interface.ts @@ -45,6 +45,14 @@ export interface PostProps { /**Optional fullName URL of the author for display name */ fullName?: string; + /** + * Whether the author currently carries the paid verification badge. + * + * Derived from `User.verifiedUntil` at read time rather than stored, + * so one that has run out disappears without anything noticing. + */ + isVerified?: boolean; + isMe?: boolean; }; diff --git a/src/core/domain/interfaces/profile-props.interface.ts b/src/core/domain/interfaces/profile-props.interface.ts index 61c1263f..95dabda5 100644 --- a/src/core/domain/interfaces/profile-props.interface.ts +++ b/src/core/domain/interfaces/profile-props.interface.ts @@ -63,6 +63,9 @@ export interface ProfileProps { /** The username of the associated user */ username: string; + /** Whether the account carries the paid verification badge */ + isVerified?: boolean; + /** Number of users following this profile */ followersCount: number; diff --git a/src/core/domain/interfaces/quoted-post.interface.ts b/src/core/domain/interfaces/quoted-post.interface.ts index 58dd6448..ec8be424 100644 --- a/src/core/domain/interfaces/quoted-post.interface.ts +++ b/src/core/domain/interfaces/quoted-post.interface.ts @@ -43,6 +43,9 @@ export interface QuotedPostSnapshot { /** Optional avatar URL of the author for display purposes */ avatarUrl?: string; + /** Whether the author carries the paid verification badge */ + isVerified?: boolean; + /** Optional display name of the author */ fullName?: string; }; diff --git a/src/core/domain/interfaces/subscription-props.interface.ts b/src/core/domain/interfaces/subscription-props.interface.ts new file mode 100644 index 00000000..b3c8479a --- /dev/null +++ b/src/core/domain/interfaces/subscription-props.interface.ts @@ -0,0 +1,39 @@ +import type { BillingProvider, SubscriptionStatus } from "@core/domain/enums"; + +/** + * The persisted shape of one account's billing relationship. + */ +export interface SubscriptionProps { + /** Set once persisted. */ + id?: string; + + userId: string; + + provider: BillingProvider; + + /** The provider's identifier for the payer, kept across resubscriptions. */ + providerCustomerId?: string | null; + + /** The provider's identifier for the current subscription. */ + providerSubscriptionId?: string | null; + + status: SubscriptionStatus; + + /** When the paid period ends. */ + currentPeriodEnd?: Date | null; + + /** The user cancelled, but the period they paid for is still running. */ + cancelAtPeriodEnd?: boolean; + + /** + * Timestamp of the provider event this row was last written from. + * + * The out-of-order guard: store notifications are not ordered, and each + * carries the whole state rather than a change to it. + */ + lastEventAt?: Date | null; + + createdAt?: Date; + + updatedAt?: Date; +} diff --git a/src/core/ports/repositories/block.repository.ts b/src/core/ports/repositories/block.repository.ts index 649f25cd..d320b0a2 100644 --- a/src/core/ports/repositories/block.repository.ts +++ b/src/core/ports/repositories/block.repository.ts @@ -9,6 +9,9 @@ export interface BlockedUserSummary { username: string; fullName: string; avatarUrl: string; + + /** Whether the account carries the paid verification badge */ + isVerified: boolean; bio: string | null; } diff --git a/src/core/ports/repositories/follow.repository.ts b/src/core/ports/repositories/follow.repository.ts index 6c89eff1..5b4befc7 100644 --- a/src/core/ports/repositories/follow.repository.ts +++ b/src/core/ports/repositories/follow.repository.ts @@ -58,6 +58,9 @@ export interface IFollowRepository { username: string; fullName: string; avatarUrl: string; + + /** Whether the account carries the paid verification badge */ + isVerified: boolean; bio: string | null; }[] >; @@ -79,6 +82,9 @@ export interface IFollowRepository { username: string; fullName: string; avatarUrl: string; + + /** Whether the account carries the paid verification badge */ + isVerified: boolean; bio: string | null; }[] >; diff --git a/src/core/ports/repositories/subscription.repository.ts b/src/core/ports/repositories/subscription.repository.ts new file mode 100644 index 00000000..68a660f8 --- /dev/null +++ b/src/core/ports/repositories/subscription.repository.ts @@ -0,0 +1,76 @@ +import type { Subscription } from "@core/domain/entities/subscription.entity"; + +/** + * Repository interface for billing relationships and the badge they grant. + */ +export interface ISubscriptionRepository { + /** + * Reads an account's billing row. + * + * @param userId - The account to look up. + * @returns Its subscription, or null for an account that has never had one. + */ + findByUserId(userId: string): Promise; + + /** + * Reads a billing row by the provider's identifier for it. + * + * How a store notification finds the account it concerns: the provider + * knows its own subscription id and nothing about ours. + * + * @param providerSubscriptionId - The provider's identifier. + * @returns The subscription, or null when no account claims it. + */ + findByProviderSubscriptionId( + providerSubscriptionId: string, + ): Promise; + + /** + * Writes an account's billing row, creating it if there is none. + * + * Keyed on the user, because there is exactly one billing relationship per + * account and it outlives any particular subscription. + * + * @param subscription - The state to store. + * @returns The stored subscription. + */ + save(subscription: Subscription): Promise; + + /** + * Sets the account's badge expiry. + * + * Separate from {@link save} because the two are written together but read + * apart: every author query reads `verifiedUntil` and none of them touch + * this table. + * + * @param userId - The account whose badge is being set. + * @param verifiedUntil - When it expires, or null to remove it. + */ + setVerifiedUntil(userId: string, verifiedUntil: Date | null): Promise; + + /** + * Reads the subscriptions the nightly reconcile has to look at. + * + * Everything that is still live at the provider, plus everything belonging + * to an account that has since been suspended or deleted - the second + * group being the one nothing else will ever notice, because a ban is + * applied by hand in SQL and has no code path to hook. + * + * @param limit - Most rows to return in one pass. + * @returns The subscriptions to reconcile, with their owner's state. + */ + findReconcilable(limit: number): Promise; +} + +/** + * A subscription, with the little about its owner that reconciliation needs. + */ +export interface ReconcilableSubscription { + subscription: Subscription; + + /** The account is suspended. */ + isBanned: boolean; + + /** The account is soft-deleted and awaiting purge. */ + isDeleted: boolean; +} diff --git a/src/core/ports/services/billing.port.ts b/src/core/ports/services/billing.port.ts new file mode 100644 index 00000000..a1ff5805 --- /dev/null +++ b/src/core/ports/services/billing.port.ts @@ -0,0 +1,60 @@ +import type { SubscriptionStatus } from "@core/domain/enums"; + +/** + * A subscription as the provider currently describes it. + * + * Absolute state, never a change: this is what the store says is true right + * now, which is what makes applying the same notification twice harmless. + */ +export interface ProviderSubscription { + providerSubscriptionId: string; + + /** The provider's identifier for the payer, when it exposes one. */ + providerCustomerId?: string | null; + + status: SubscriptionStatus; + + /** When the paid period ends, if there is one. */ + currentPeriodEnd?: Date | null; + + /** The user has cancelled but the paid period is still running. */ + cancelAtPeriodEnd?: boolean; + + /** When the provider says this state was reached. */ + eventAt?: Date | null; +} + +/** + * Port interface for talking to whichever store or gateway is billing. + * + * Two operations, because there are only two things the core needs from a + * provider: what does it say the state is, and please stop charging. Starting + * a purchase belongs to the client and the store, and nothing here should be + * able to move money. + */ +export interface BillingPort { + /** + * Reads the provider's current view of a subscription. + * + * Used by the nightly reconcile to repair anything a missed notification + * left behind. + * + * @param providerSubscriptionId - The provider's identifier. + * @returns The state, or null when the provider no longer knows it. + */ + fetchSubscription( + providerSubscriptionId: string, + ): Promise; + + /** + * Stops a subscription at the provider, immediately. + * + * Used when an account is suspended or deleted: this platform promises + * that neither keeps being charged, and that promise can only be kept by + * whoever is taking the money. + * + * @param providerSubscriptionId - The provider's identifier. + * @returns True when the provider accepted the cancellation. + */ + cancelSubscription(providerSubscriptionId: string): Promise; +} diff --git a/src/core/use-cases/article/get-articles/get-articles.usecase.ts b/src/core/use-cases/article/get-articles/get-articles.usecase.ts index d509b482..a087e62a 100644 --- a/src/core/use-cases/article/get-articles/get-articles.usecase.ts +++ b/src/core/use-cases/article/get-articles/get-articles.usecase.ts @@ -41,6 +41,9 @@ interface CachedArticle { username?: string; avatarUrl?: string; fullName?: string; + + /** Whether the author carries the paid verification badge */ + isVerified?: boolean; }; tags: string[]; mentions?: MentionedUser[]; @@ -245,6 +248,7 @@ export class GetArticlesUseCase { username: article.author.username, avatarUrl: article.author.avatarUrl, fullName: article.author.fullName, + isVerified: article.author.isVerified, }, tags: article.tags, mentions: article.mentions, diff --git a/src/core/use-cases/billing/get-subscription/get-subscription.usecase.ts b/src/core/use-cases/billing/get-subscription/get-subscription.usecase.ts new file mode 100644 index 00000000..4beeafef --- /dev/null +++ b/src/core/use-cases/billing/get-subscription/get-subscription.usecase.ts @@ -0,0 +1,69 @@ +import type { SubscriptionStatus } from "@core/domain/enums"; +import type { ISubscriptionRepository } from "@core/ports/repositories/subscription.repository"; + +/** + * What the client needs to render the subscription screen. + */ +export interface GetSubscriptionOutput { + /** Whether the badge is currently granted. */ + isVerified: boolean; + + /** When it expires, or null when there is nothing granted. */ + verifiedUntil: Date | null; + + /** Null for an account that has never subscribed. */ + status: SubscriptionStatus | null; + + /** When the paid period ends. */ + currentPeriodEnd: Date | null; + + /** The user cancelled, but the period they paid for is still running. */ + cancelAtPeriodEnd: boolean; +} + +/** + * Use case for reading an account's own subscription. + * + * Reports state, never receipts or amounts: the store owns those and shows + * them to the user itself, and the client needs none of it to decide between + * "subscribe", "you are subscribed" and "your subscription ends on the 14th". + */ +export class GetSubscriptionUseCase { + /** + * Creates a new instance of GetSubscriptionUseCase. + * + * @param subscriptionRepository - Where billing state is stored + */ + constructor( + private readonly subscriptionRepository: ISubscriptionRepository, + ) {} + + /** + * Reads the caller's subscription. + * + * @param userId - The account asking about itself + * @returns Its state, all nulls for an account that never subscribed + */ + async execute(userId: string): Promise { + const subscription = + await this.subscriptionRepository.findByUserId(userId); + + if (!subscription) { + return { + isVerified: false, + verifiedUntil: null, + status: null, + currentPeriodEnd: null, + cancelAtPeriodEnd: false, + }; + } + + return { + isVerified: subscription.isEntitled(), + verifiedUntil: subscription.entitlementUntil(), + status: subscription.status, + currentPeriodEnd: subscription.currentPeriodEnd, + cancelAtPeriodEnd: subscription.cancelAtPeriodEnd, + }; + } +} diff --git a/src/core/use-cases/billing/get-subscription/index.ts b/src/core/use-cases/billing/get-subscription/index.ts new file mode 100644 index 00000000..b3f9b638 --- /dev/null +++ b/src/core/use-cases/billing/get-subscription/index.ts @@ -0,0 +1,6 @@ +/** + * This module exports the GetSubscriptionUseCase, which reads an account its + * own subscription state. + */ +export { GetSubscriptionUseCase } from "./get-subscription.usecase"; +export type { GetSubscriptionOutput } from "./get-subscription.usecase"; diff --git a/src/core/use-cases/billing/reconcile-subscriptions/index.ts b/src/core/use-cases/billing/reconcile-subscriptions/index.ts new file mode 100644 index 00000000..769a7fe3 --- /dev/null +++ b/src/core/use-cases/billing/reconcile-subscriptions/index.ts @@ -0,0 +1,6 @@ +/** + * This module exports the ReconcileSubscriptionsUseCase, the nightly repair + * for billing state that drifted. + */ +export { ReconcileSubscriptionsUseCase } from "./reconcile-subscriptions.usecase"; +export type { ReconcileSubscriptionsOutput } from "./reconcile-subscriptions.usecase"; diff --git a/src/core/use-cases/billing/reconcile-subscriptions/reconcile-subscriptions.usecase.ts b/src/core/use-cases/billing/reconcile-subscriptions/reconcile-subscriptions.usecase.ts new file mode 100644 index 00000000..5dfe5f23 --- /dev/null +++ b/src/core/use-cases/billing/reconcile-subscriptions/reconcile-subscriptions.usecase.ts @@ -0,0 +1,122 @@ +import type { ISubscriptionRepository } from "@core/ports/repositories/subscription.repository"; +import type { BillingPort } from "@core/ports/services/billing.port"; +import type { LoggerPort } from "@core/ports/services/logger.port"; +import type { RevokeSubscriptionUseCase } from "../revoke-subscription"; +import type { SyncSubscriptionUseCase } from "../sync-subscription"; + +/** + * What one reconcile pass did. + */ +export interface ReconcileSubscriptionsOutput { + /** Rows examined. */ + examined: number; + + /** Rows the provider's answer changed. */ + repaired: number; + + /** Subscriptions cancelled because the account may no longer have one. */ + revoked: number; +} + +/** + * Use case for repairing billing state that drifted. + * + * Three jobs, one pass: + * + * **Bans.** A suspension is applied by hand in SQL - there is no endpoint and + * no admin panel - so nothing in the code ever hears about it. This is the only + * thing that will notice, and the promise that a banned account stops being + * charged rests on it entirely. + * + * **Deletions that failed to cancel.** The soft-delete cancels at the provider + * itself; when that call failed, this is the retry. + * + * **Missed notifications.** Store notifications get lost. Because the badge + * expires on its own the damage is bounded either way, but a subscription that + * renewed while a notification went missing would otherwise lose its badge at + * the end of a period the user has already paid past. + */ +export class ReconcileSubscriptionsUseCase { + /** + * Creates a new instance of ReconcileSubscriptionsUseCase. + * + * @param subscriptionRepository - Where billing state is stored + * @param billingService - The provider's current view + * @param syncSubscriptionUseCase - Applies what the provider says + * @param revokeSubscriptionUseCase - Cuts an account off + * @param logger - Records what could not be repaired + */ + constructor( + private readonly subscriptionRepository: ISubscriptionRepository, + private readonly billingService: BillingPort, + private readonly syncSubscriptionUseCase: SyncSubscriptionUseCase, + private readonly revokeSubscriptionUseCase: RevokeSubscriptionUseCase, + private readonly logger: LoggerPort, + ) {} + + /** + * Runs one pass. + * + * @param limit - Most rows to examine + * @returns What the pass did, for the scheduler's log line + */ + async execute(limit: number): Promise { + const rows = await this.subscriptionRepository.findReconcilable(limit); + + const output: ReconcileSubscriptionsOutput = { + examined: rows.length, + repaired: 0, + revoked: 0, + }; + + for (const row of rows) { + const { subscription, isBanned, isDeleted } = row; + + if (isBanned || isDeleted) { + const revoked = await this.revokeSubscriptionUseCase.execute( + subscription.userId, + ); + + if (revoked) output.revoked++; + continue; + } + + if (!subscription.providerSubscriptionId) continue; + + try { + const state = await this.billingService.fetchSubscription( + subscription.providerSubscriptionId, + ); + + // A provider that no longer knows the subscription is not the + // same as one saying it ended, and guessing between them is + // how a paying user loses a badge. Left alone: the expiry + // already on the row will retire it if it really is over. + if (!state) continue; + + const result = await this.syncSubscriptionUseCase.execute({ + userId: subscription.userId, + provider: subscription.provider, + // The provider is authoritative here by definition, and a + // stored `lastEventAt` from a notification that arrived + // later would otherwise make this read look stale. + state: { ...state, eventAt: state.eventAt ?? new Date() }, + }); + + if (result.applied) output.repaired++; + } catch (error: unknown) { + this.logger.error( + { + err: error, + userId: subscription.userId, + providerSubscriptionId: + subscription.providerSubscriptionId, + }, + "Failed to reconcile a subscription", + ); + } + } + + return output; + } +} diff --git a/src/core/use-cases/billing/revoke-subscription/index.ts b/src/core/use-cases/billing/revoke-subscription/index.ts new file mode 100644 index 00000000..9f967ffe --- /dev/null +++ b/src/core/use-cases/billing/revoke-subscription/index.ts @@ -0,0 +1,5 @@ +/** + * This module exports the RevokeSubscriptionUseCase, which stops a + * subscription when an account is suspended or deleted. + */ +export { RevokeSubscriptionUseCase } from "./revoke-subscription.usecase"; diff --git a/src/core/use-cases/billing/revoke-subscription/revoke-subscription.usecase.ts b/src/core/use-cases/billing/revoke-subscription/revoke-subscription.usecase.ts new file mode 100644 index 00000000..8dcbc6c8 --- /dev/null +++ b/src/core/use-cases/billing/revoke-subscription/revoke-subscription.usecase.ts @@ -0,0 +1,89 @@ +import { Subscription } from "@core/domain/entities/subscription.entity"; +import { SubscriptionStatus } from "@core/domain/enums"; +import type { ISubscriptionRepository } from "@core/ports/repositories/subscription.repository"; +import type { BillingPort } from "@core/ports/services/billing.port"; +import type { LoggerPort } from "@core/ports/services/logger.port"; + +/** + * Use case for stopping a subscription because the account may no longer have + * one. + * + * The platform promises that a suspended or deleted account stops being + * charged. Only the provider can keep that promise, so this asks it to - and + * clears the badge whether or not it answers, because the account has lost the + * badge either way and a provider that is down must not make a banned user + * verified for another day. + */ +export class RevokeSubscriptionUseCase { + /** + * Creates a new instance of RevokeSubscriptionUseCase. + * + * @param subscriptionRepository - Where billing state is stored + * @param billingService - The provider that is taking the money + * @param logger - Records a cancellation the provider refused + */ + constructor( + private readonly subscriptionRepository: ISubscriptionRepository, + private readonly billingService: BillingPort, + private readonly logger: LoggerPort, + ) {} + + /** + * Cancels an account's subscription at the provider and locally. + * + * Safe to call for an account with no subscription, and safe to call + * twice: both are the ordinary case when a deletion is retried or a ban is + * applied to somebody who never paid. + * + * @param userId - The account being cut off + * @returns True when there was something to revoke + */ + async execute(userId: string): Promise { + const subscription = + await this.subscriptionRepository.findByUserId(userId); + + if (!subscription) return false; + + if (subscription.status === SubscriptionStatus.REVOKED) return false; + + if (subscription.providerSubscriptionId) { + try { + await this.billingService.cancelSubscription( + subscription.providerSubscriptionId, + ); + } catch (error: unknown) { + // Recorded and carried on. The local state must still say + // revoked - a provider outage cannot be allowed to leave a + // banned account wearing a badge - and the nightly reconcile + // will try the cancellation again. + this.logger.error( + { + err: error, + userId, + providerSubscriptionId: + subscription.providerSubscriptionId, + }, + "Provider refused a subscription cancellation", + ); + } + } + + await this.subscriptionRepository.save( + Subscription.with({ + id: subscription.id, + userId: subscription.userId, + provider: subscription.provider, + providerCustomerId: subscription.providerCustomerId, + providerSubscriptionId: subscription.providerSubscriptionId, + status: SubscriptionStatus.REVOKED, + currentPeriodEnd: subscription.currentPeriodEnd, + cancelAtPeriodEnd: subscription.cancelAtPeriodEnd, + lastEventAt: subscription.lastEventAt, + }), + ); + + await this.subscriptionRepository.setVerifiedUntil(userId, null); + + return true; + } +} diff --git a/src/core/use-cases/billing/sync-subscription/index.ts b/src/core/use-cases/billing/sync-subscription/index.ts new file mode 100644 index 00000000..fa0c695f --- /dev/null +++ b/src/core/use-cases/billing/sync-subscription/index.ts @@ -0,0 +1,9 @@ +/** + * This module exports the SyncSubscriptionUseCase, the single door through + * which provider billing state enters the system. + */ +export { SyncSubscriptionUseCase } from "./sync-subscription.usecase"; +export type { + SyncSubscriptionInput, + SyncSubscriptionOutput, +} from "./sync-subscription.usecase"; diff --git a/src/core/use-cases/billing/sync-subscription/sync-subscription.usecase.ts b/src/core/use-cases/billing/sync-subscription/sync-subscription.usecase.ts new file mode 100644 index 00000000..3476eca1 --- /dev/null +++ b/src/core/use-cases/billing/sync-subscription/sync-subscription.usecase.ts @@ -0,0 +1,138 @@ +import { Subscription } from "@core/domain/entities/subscription.entity"; +import type { BillingProvider } from "@core/domain/enums"; +import type { ISubscriptionRepository } from "@core/ports/repositories/subscription.repository"; +import type { ProviderSubscription } from "@core/ports/services/billing.port"; +import type { LoggerPort } from "@core/ports/services/logger.port"; + +/** + * Input DTO for the SyncSubscriptionUseCase. + */ +export interface SyncSubscriptionInput { + /** + * The account the purchase belongs to. + * + * Comes from the purchase itself - the store carries an account id the app + * put there - and not from whoever delivered the notification. + */ + userId: string; + + provider: BillingProvider; + + /** What the provider currently says is true. */ + state: ProviderSubscription; +} + +/** + * What the sync did, for the caller's log line. + */ +export interface SyncSubscriptionOutput { + applied: boolean; + + /** Why it was not, when it was not. */ + reason?: "stale-event" | "claimed-by-another-account"; + + /** The badge expiry the account ended up with. */ + verifiedUntil: Date | null; +} + +/** + * Use case for writing what a provider says about a subscription. + * + * The single door through which billing state enters this system. Every + * adapter - a store notification, the nightly reconcile, a purchase being + * verified - arrives here with the provider's *absolute* state rather than a + * change to apply, which is what makes delivering the same notification twice + * harmless. + * + * It is also the only place that writes `verifiedUntil`, so "when does the tick + * appear and disappear" has exactly one answer. + */ +export class SyncSubscriptionUseCase { + /** + * Creates a new instance of SyncSubscriptionUseCase. + * + * @param subscriptionRepository - Where billing state is stored + * @param logger - Records a refusal somebody will need to explain + */ + constructor( + private readonly subscriptionRepository: ISubscriptionRepository, + private readonly logger: LoggerPort, + ) {} + + /** + * Applies the provider's state to the account it belongs to. + * + * @param input - Whose subscription, and what the provider says + * @returns Whether it was applied, and the resulting badge expiry + */ + async execute( + input: SyncSubscriptionInput, + ): Promise { + const { userId, provider, state } = input; + + const claimed = + await this.subscriptionRepository.findByProviderSubscriptionId( + state.providerSubscriptionId, + ); + + // One purchase, one account. A store subscription that already belongs + // to somebody else is refused rather than moved: the alternative is a + // shared receipt granting a badge to whoever presents it last. + if (claimed && claimed.userId !== userId) { + this.logger.error( + { + userId, + claimedBy: claimed.userId, + providerSubscriptionId: state.providerSubscriptionId, + }, + "Refused a subscription already claimed by another account", + ); + + return { + applied: false, + reason: "claimed-by-another-account", + verifiedUntil: null, + }; + } + + const existing = + claimed ?? (await this.subscriptionRepository.findByUserId(userId)); + + // Store notifications are not ordered, and each carries the whole + // state. An older one applied after a newer one would not just lose an + // update - it would reinstate a subscription that has ended. + if (existing && !existing.accepts(state.eventAt ?? null)) { + return { + applied: false, + reason: "stale-event", + verifiedUntil: existing.entitlementUntil(), + }; + } + + const subscription = Subscription.with({ + id: existing?.id, + userId, + provider, + providerCustomerId: + state.providerCustomerId ?? + existing?.providerCustomerId ?? + null, + providerSubscriptionId: state.providerSubscriptionId, + status: state.status, + currentPeriodEnd: state.currentPeriodEnd ?? null, + cancelAtPeriodEnd: state.cancelAtPeriodEnd ?? false, + lastEventAt: state.eventAt ?? existing?.lastEventAt ?? null, + }); + + await this.subscriptionRepository.save(subscription); + + const verifiedUntil = subscription.entitlementUntil(); + + await this.subscriptionRepository.setVerifiedUntil( + userId, + verifiedUntil, + ); + + return { applied: true, verifiedUntil }; + } +} diff --git a/src/core/use-cases/follow-user/get-followers/get-followers-usecase.output.ts b/src/core/use-cases/follow-user/get-followers/get-followers-usecase.output.ts index 16f74da2..9dbda8bb 100644 --- a/src/core/use-cases/follow-user/get-followers/get-followers-usecase.output.ts +++ b/src/core/use-cases/follow-user/get-followers/get-followers-usecase.output.ts @@ -18,6 +18,9 @@ export interface GetFollowersUseCaseOutput { * The URL of the follower user's avatar image. This is used for display purposes in the followers list. It may be a default avatar if the user has not set one. */ avatarUrl: string; + + /** Whether the account carries the paid verification badge */ + isVerified: boolean; /** * The bio of the follower user, which is a short description they can set on their profile. This may be null if the user has not provided a bio. It is used for display purposes in the followers list. */ diff --git a/src/core/use-cases/follow-user/get-following/get-following-usecase.output.ts b/src/core/use-cases/follow-user/get-following/get-following-usecase.output.ts index ff10a7bc..8ac175d9 100644 --- a/src/core/use-cases/follow-user/get-following/get-following-usecase.output.ts +++ b/src/core/use-cases/follow-user/get-following/get-following-usecase.output.ts @@ -18,6 +18,9 @@ export interface GetFollowingUseCaseOutput { * The URL of the following user's avatar image. This is used for display purposes in the following list. It may be a default avatar if the user has not set one. */ avatarUrl: string; + + /** Whether the account carries the paid verification badge */ + isVerified: boolean; /** * The bio of the following user, which is a short description they can set on their profile. This may be null if the user has not provided a bio. It is used for display purposes in the following list. */ diff --git a/src/core/use-cases/profile/get-bot-profiles/get-bot-profiles.output.ts b/src/core/use-cases/profile/get-bot-profiles/get-bot-profiles.output.ts index ffafbba1..2a11fd9f 100644 --- a/src/core/use-cases/profile/get-bot-profiles/get-bot-profiles.output.ts +++ b/src/core/use-cases/profile/get-bot-profiles/get-bot-profiles.output.ts @@ -16,6 +16,9 @@ export interface BotProfileItem { /** Storage path or URL of the bot's avatar image. */ avatarUrl: string; + /** Whether the account carries the paid verification badge */ + isVerified: boolean; + /** Storage path or URL of the bot's banner image. */ bannerUrl: string; diff --git a/src/core/use-cases/profile/get-bot-profiles/get-bot-profiles.usecase.ts b/src/core/use-cases/profile/get-bot-profiles/get-bot-profiles.usecase.ts index 828401cd..44bef209 100644 --- a/src/core/use-cases/profile/get-bot-profiles/get-bot-profiles.usecase.ts +++ b/src/core/use-cases/profile/get-bot-profiles/get-bot-profiles.usecase.ts @@ -61,6 +61,7 @@ export class GetBotProfilesUseCase { username: profile.username, fullName: profile.fullName, avatarUrl: profile.avatarUrl, + isVerified: profile.isVerified, bannerUrl: profile.bannerUrl, bio: profile.bio, categories: profile.categories, diff --git a/src/core/use-cases/profile/get-suggested-users/get-suggested-users.output.ts b/src/core/use-cases/profile/get-suggested-users/get-suggested-users.output.ts index 8935b3ac..e985c1ca 100644 --- a/src/core/use-cases/profile/get-suggested-users/get-suggested-users.output.ts +++ b/src/core/use-cases/profile/get-suggested-users/get-suggested-users.output.ts @@ -3,6 +3,9 @@ export interface SuggestedUserItem { username: string; fullName: string; avatarUrl: string; + + /** Whether the account carries the paid verification badge */ + isVerified: boolean; bannerUrl: string; bio: string | null; followersCount: number; diff --git a/src/core/use-cases/profile/get-suggested-users/get-suggested-users.usecase.ts b/src/core/use-cases/profile/get-suggested-users/get-suggested-users.usecase.ts index ffed53ee..5b5aea13 100644 --- a/src/core/use-cases/profile/get-suggested-users/get-suggested-users.usecase.ts +++ b/src/core/use-cases/profile/get-suggested-users/get-suggested-users.usecase.ts @@ -21,6 +21,7 @@ export class GetSuggestedUsersUseCase { username: profile.username, fullName: profile.fullName, avatarUrl: profile.avatarUrl, + isVerified: profile.isVerified, bannerUrl: profile.bannerUrl, bio: profile.bio, followersCount: profile.followersCount, diff --git a/src/core/use-cases/shared/verification/is-verified.ts b/src/core/use-cases/shared/verification/is-verified.ts new file mode 100644 index 00000000..a0ec37f7 --- /dev/null +++ b/src/core/use-cases/shared/verification/is-verified.ts @@ -0,0 +1,20 @@ +/** + * Whether a stored badge expiry still grants the badge. + * + * The one place the denormalised column is interpreted. Every read that shows + * a person asks this rather than comparing dates itself, so the tick appears + * and disappears at the same moment everywhere - and so that a column holding + * an expiry can never be mistaken for a boolean that is simply set. + * + * @param verifiedUntil - The stored expiry, if any + * @param now - Reference time + * @returns True while the badge is still granted + */ +export function isVerified( + verifiedUntil: Date | null | undefined, + now: Date = new Date(), +): boolean { + return verifiedUntil !== null && verifiedUntil !== undefined + ? verifiedUntil.getTime() > now.getTime() + : false; +} diff --git a/src/core/use-cases/user/soft-delete/soft-delete-user.usecase.ts b/src/core/use-cases/user/soft-delete/soft-delete-user.usecase.ts index 5c9337a7..b6b15afe 100644 --- a/src/core/use-cases/user/soft-delete/soft-delete-user.usecase.ts +++ b/src/core/use-cases/user/soft-delete/soft-delete-user.usecase.ts @@ -3,6 +3,7 @@ import { NotFoundError } from "@core/errors/common/not-found.error"; import type { EmailPort } from "@core/ports/services/email.port"; import type { PasswordPort } from "@core/ports/services/password.port"; import type { IUserRepository } from "@core/ports/repositories/user.repository"; +import type { RevokeSubscriptionUseCase } from "@core/use-cases/billing/revoke-subscription"; import type { SoftDeleteUserUseCaseInput } from "./soft-delete-user-usecase.input"; /** @@ -19,11 +20,13 @@ export class SoftDeleteUserUseCase { * @param userRepository - Repository for managing user data * @param passwordService - Service for password verification * @param emailService - Service for sending emails + * @param revokeSubscriptionUseCase - Stops the account being charged */ constructor( private readonly userRepository: IUserRepository, private readonly passwordService: PasswordPort, private readonly emailService: EmailPort, + private readonly revokeSubscriptionUseCase: RevokeSubscriptionUseCase, ) {} /** @@ -55,6 +58,16 @@ export class SoftDeleteUserUseCase { await this.userRepository.softDeleteById(input.id); + // Awaited, not fired and forgotten: this platform promises that a + // deleted account stops being charged, and a promise about somebody's + // money is not something to lose to a dropped promise. It cancels at + // the provider immediately rather than at the end of the period, + // because the account is going away either way. + // + // The nightly reconcile retries anything the provider refused, which + // is why a failure here does not have to stop the deletion. + await this.revokeSubscriptionUseCase.execute(input.id); + await this.emailService.sendDeleteUserEmail({ to: user.email, }); diff --git a/src/http/controllers/billing.controller.ts b/src/http/controllers/billing.controller.ts new file mode 100644 index 00000000..e6c59c8a --- /dev/null +++ b/src/http/controllers/billing.controller.ts @@ -0,0 +1,47 @@ +import type { GetSubscriptionUseCase } from "@core/use-cases/billing/get-subscription"; +import type { FastifyReply, FastifyRequest } from "fastify"; + +/** + * Controller for the subscription endpoints. + */ +export class BillingController { + /** + * Creates a new BillingController instance. + * + * @param getSubscriptionUseCase - Use case that reads an account's own + * subscription + */ + constructor( + private readonly getSubscriptionUseCase: GetSubscriptionUseCase, + ) {} + + /** + * Reads the caller's subscription. + * + * Only ever the caller's own: there is no path here that takes a user id, + * because whether somebody pays is not other people's business. What *is* + * public is the badge, and that already travels on every profile. + * + * @param request - The authenticated request + * @param reply - The reply to send + */ + async subscription( + request: FastifyRequest, + reply: FastifyReply, + ): Promise { + const subscription = await this.getSubscriptionUseCase.execute( + request.user!.id, + ); + + reply.status(200).send({ + data: { + ...subscription, + verifiedUntil: + subscription.verifiedUntil?.toISOString() ?? null, + currentPeriodEnd: + subscription.currentPeriodEnd?.toISOString() ?? null, + }, + meta: { timestamp: new Date().toISOString() }, + }); + } +} diff --git a/src/http/plugins/custom/subscription-reconcile.plugin.ts b/src/http/plugins/custom/subscription-reconcile.plugin.ts new file mode 100644 index 00000000..4c0ef588 --- /dev/null +++ b/src/http/plugins/custom/subscription-reconcile.plugin.ts @@ -0,0 +1,42 @@ +import type { FastifyInstance } from "fastify"; +import fastifyPlugin from "fastify-plugin"; + +function subscriptionReconcilePlugin(fastify: FastifyInstance): void { + const subscriptionReconcileScheduler = + fastify.diContainer.cradle.subscriptionReconcileScheduler; + + fastify.addHook("onReady", () => { + subscriptionReconcileScheduler.start(); + + fastify.log.info( + { + context: "SystemScheduler", + jobName: "SubscriptionReconcile", + status: "Started", + config: { + cronExpression: fastify.config.SUBSCRIPTION_RECONCILE_CRON, + batchSize: fastify.config.SUBSCRIPTION_RECONCILE_BATCH_SIZE, + }, + }, + "Subscription reconcile scheduler initialized.", + ); + }); + + fastify.addHook("onClose", () => { + subscriptionReconcileScheduler.stop(); + + fastify.log.info( + { + context: "SystemScheduler", + jobName: "SubscriptionReconcile", + status: "Stopped", + }, + "Subscription reconcile scheduler stopped safely.", + ); + }); +} + +export default fastifyPlugin(subscriptionReconcilePlugin, { + name: "subscription-reconcile-plugin", + dependencies: ["di-plugin", "prisma-plugin", "env-plugin"], +}); diff --git a/src/http/plugins/di/controllers.di.ts b/src/http/plugins/di/controllers.di.ts index 622dea69..0ee8d108 100644 --- a/src/http/plugins/di/controllers.di.ts +++ b/src/http/plugins/di/controllers.di.ts @@ -11,6 +11,7 @@ import { BlockController } from "@controllers/block.controller"; import { ReportController } from "@controllers/report.controller"; import { MetaController } from "@controllers/meta.controller"; import { DeviceController } from "@controllers/device.controller"; +import { BillingController } from "@controllers/billing.controller"; import { CommentController } from "@controllers/comment.controller"; import { BookmarkController } from "@controllers/bookmark.controller"; import { TrendController } from "@controllers/trend.controller"; @@ -60,6 +61,7 @@ export const controllersModule = { reportController: asClass(ReportController).singleton(), metaController: asClass(MetaController).singleton(), deviceController: asClass(DeviceController).singleton(), + billingController: asClass(BillingController).singleton(), notificationController: asClass(NotificationController).singleton(), postController: asClass(PostController).singleton(), commentController: asClass(CommentController).singleton(), diff --git a/src/http/plugins/di/external.di.ts b/src/http/plugins/di/external.di.ts index 730b5951..9e28dbe4 100644 --- a/src/http/plugins/di/external.di.ts +++ b/src/http/plugins/di/external.di.ts @@ -4,6 +4,7 @@ import { ExpoPushService, NoopPushService, } from "@infrastructure/external/push/expo-push.service"; +import { NoopBillingService } from "@infrastructure/external/billing/noop-billing.service"; import { GithubAuthService } from "@infrastructure/external/github-auth.service"; import { GoogleAuthService } from "@infrastructure/external/google-auth.service"; import { S3StorageService } from "@infrastructure/external/s3-storage.service"; @@ -28,6 +29,13 @@ export const externalModule = { logger, ); }).singleton(), + /** + * The store or gateway that bills. There is no adapter yet - the store + * one arrives with the purchase flow - and the stub deliberately never + * reports a subscription as active, so a misconfigured environment + * cannot hand out free badges. + */ + billingService: asClass(NoopBillingService).singleton(), emailService: asFunction((config, logger) => { return new EmailService( diff --git a/src/http/plugins/di/jobs.di.ts b/src/http/plugins/di/jobs.di.ts index e32defaf..b3dd8900 100644 --- a/src/http/plugins/di/jobs.di.ts +++ b/src/http/plugins/di/jobs.di.ts @@ -15,6 +15,8 @@ import { ReportDigestJob } from "@infrastructure/jobs/report/report-digest.job"; import { ReportDigestScheduler } from "@infrastructure/jobs/report/report-digest.scheduler"; import { DevicePurgeJob } from "@infrastructure/jobs/device/device-purge.job"; import { DevicePurgeScheduler } from "@infrastructure/jobs/device/device-purge.scheduler"; +import { SubscriptionReconcileJob } from "@infrastructure/jobs/billing/subscription-reconcile.job"; +import { SubscriptionReconcileScheduler } from "@infrastructure/jobs/billing/subscription-reconcile.scheduler"; import { ReportPurgeJob } from "@infrastructure/jobs/report/report-purge.job"; import { ReportPurgeScheduler } from "@infrastructure/jobs/report/report-purge.scheduler"; import { MessageRetentionJob } from "@infrastructure/jobs/message/message-retention.job"; @@ -31,6 +33,7 @@ export const jobsModule = { reportDigestJob: asClass(ReportDigestJob).singleton(), reportPurgeJob: asClass(ReportPurgeJob).singleton(), devicePurgeJob: asClass(DevicePurgeJob).singleton(), + subscriptionReconcileJob: asClass(SubscriptionReconcileJob).singleton(), // --- Schedulers --- userPurgeScheduler: asFunction((userPurgeJob, config, logger) => { @@ -126,6 +129,18 @@ export const jobsModule = { logger, ); }).singleton(), + subscriptionReconcileScheduler: asFunction( + (subscriptionReconcileJob, config, logger) => { + return new SubscriptionReconcileScheduler( + subscriptionReconcileJob, + { + cronExpression: config.SUBSCRIPTION_RECONCILE_CRON, + batchSize: config.SUBSCRIPTION_RECONCILE_BATCH_SIZE, + }, + logger, + ); + }, + ).singleton(), reportPurgeScheduler: asFunction((reportPurgeJob, config, logger) => { return new ReportPurgeScheduler( diff --git a/src/http/plugins/di/persistence.di.ts b/src/http/plugins/di/persistence.di.ts index de327a9f..0143483f 100644 --- a/src/http/plugins/di/persistence.di.ts +++ b/src/http/plugins/di/persistence.di.ts @@ -10,6 +10,7 @@ import { PrismaNotificationRepository } from "@infrastructure/persistence/reposi import { PrismaDigestDeliveryRepository } from "@infrastructure/persistence/repositories/prisma-digest-delivery.repository"; import { PrismaReportRepository } from "@infrastructure/persistence/repositories/prisma-report.repository"; import { PrismaDeviceTokenRepository } from "@infrastructure/persistence/repositories/prisma-device-token.repository"; +import { PrismaSubscriptionRepository } from "@infrastructure/persistence/repositories/prisma-subscription.repository"; import { PrismaReportDigestDeliveryRepository } from "@infrastructure/persistence/repositories/prisma-report-digest-delivery.repository"; import { PrismaUserInterestRepository } from "@infrastructure/persistence/repositories/prisma-user-interest.repository"; import { PrismaPostRepository } from "@infrastructure/persistence/repositories/prisma-post.repository"; @@ -145,6 +146,10 @@ export const persistenceModule = { * Device token repository for push notification addresses */ deviceTokenRepository: asClass(PrismaDeviceTokenRepository).singleton(), + /** + * Subscription repository for the paid verification badge + */ + subscriptionRepository: asClass(PrismaSubscriptionRepository).singleton(), reportDigestDeliveryRepository: asClass( PrismaReportDigestDeliveryRepository, ).singleton(), diff --git a/src/http/plugins/di/use-cases.di.ts b/src/http/plugins/di/use-cases.di.ts index 59cfb80c..5b604131 100644 --- a/src/http/plugins/di/use-cases.di.ts +++ b/src/http/plugins/di/use-cases.di.ts @@ -107,6 +107,10 @@ import { RegisterDeviceUseCase } from "@core/use-cases/device/register-device"; import { UnregisterDeviceUseCase } from "@core/use-cases/device/unregister-device"; import { PurgeStaleDevicesUseCase } from "@core/use-cases/device/purge-stale-devices"; import { SendPushNotificationUseCase } from "@core/use-cases/notification/send-push"; +import { SyncSubscriptionUseCase } from "@core/use-cases/billing/sync-subscription"; +import { RevokeSubscriptionUseCase } from "@core/use-cases/billing/revoke-subscription"; +import { GetSubscriptionUseCase } from "@core/use-cases/billing/get-subscription"; +import { ReconcileSubscriptionsUseCase } from "@core/use-cases/billing/reconcile-subscriptions"; import { REPORT_EXCERPT_LENGTH, REPORT_MAX_DETAILS, @@ -233,6 +237,28 @@ export const useCasesModule = { SendPushNotificationUseCase, ).singleton(), + /** + * Use case that applies what a provider says about a subscription + */ + syncSubscriptionUseCase: asClass(SyncSubscriptionUseCase).singleton(), + + /** + * Use case that stops a subscription when an account is cut off + */ + revokeSubscriptionUseCase: asClass(RevokeSubscriptionUseCase).singleton(), + + /** + * Use case that reads an account its own subscription + */ + getSubscriptionUseCase: asClass(GetSubscriptionUseCase).singleton(), + + /** + * Use case that repairs billing state nightly + */ + reconcileSubscriptionsUseCase: asClass( + ReconcileSubscriptionsUseCase, + ).singleton(), + /** * Use case for reporting a post or a comment */ diff --git a/src/http/routes/billing.routes.ts b/src/http/routes/billing.routes.ts new file mode 100644 index 00000000..28b5edd4 --- /dev/null +++ b/src/http/routes/billing.routes.ts @@ -0,0 +1,37 @@ +/** + * Billing routes module + * + * One endpoint today: what does my subscription look like. Purchasing happens + * in the store, and the notification that follows it belongs to the store + * adapter rather than here. + * + * @author TDN Team + * @version 1.0.0 + */ + +import { RateLimitPolicies } from "@plugins/rate-limit.plugin"; +import { SubscriptionResponseSchema } from "@typings/schemas/billing/subscription.schema"; +import type { FastifyInstance } from "fastify"; + +/** + * Sets up the billing routes on the Fastify instance. + * + * @param fastify - The Fastify application instance + * @returns void + */ +export default function billingRoutes(fastify: FastifyInstance): void { + const billingController = fastify.diContainer.cradle.billingController; + + fastify.get( + "/billing/subscription", + { + schema: { + response: { 200: SubscriptionResponseSchema }, + tags: ["Billing"], + }, + onRequest: [fastify.authenticate], + config: { rateLimit: RateLimitPolicies.STANDARD }, + }, + billingController.subscription.bind(billingController), + ); +} diff --git a/src/http/types/fastify-awilix.d.ts b/src/http/types/fastify-awilix.d.ts index 1c056d56..c3900ee8 100644 --- a/src/http/types/fastify-awilix.d.ts +++ b/src/http/types/fastify-awilix.d.ts @@ -11,6 +11,8 @@ import type { ReportController } from "@controllers/report.controller"; import type { MetaController } from "@controllers/meta.controller"; import type { DeviceController } from "@controllers/device.controller"; import type { DevicePurgeScheduler } from "@infrastructure/jobs/device/device-purge.scheduler"; +import type { BillingController } from "@controllers/billing.controller"; +import type { SubscriptionReconcileScheduler } from "@infrastructure/jobs/billing/subscription-reconcile.scheduler"; import type { ReportDigestScheduler } from "@infrastructure/jobs/report/report-digest.scheduler"; import type { ReportPurgeScheduler } from "@infrastructure/jobs/report/report-purge.scheduler"; import type { WebSocketManager } from "@infrastructure/realtime/websocket/websocket-manager"; @@ -95,6 +97,11 @@ declare module "@fastify/awilix" { /** Scheduler that drops abandoned push registrations */ devicePurgeScheduler: DevicePurgeScheduler; + /** Controller for the subscription endpoint */ + billingController: BillingController; + + /** Scheduler that repairs billing state nightly */ + subscriptionReconcileScheduler: SubscriptionReconcileScheduler; /** Scheduler for the morning summary of open reports */ reportDigestScheduler: ReportDigestScheduler; diff --git a/src/http/types/schemas/article/article-item.schema.ts b/src/http/types/schemas/article/article-item.schema.ts index 34139ea1..1a99aae6 100644 --- a/src/http/types/schemas/article/article-item.schema.ts +++ b/src/http/types/schemas/article/article-item.schema.ts @@ -8,6 +8,7 @@ export const ArticleAuthorSchema = FBType.Object({ username: FBType.String(), fullName: FBType.Union([FBType.String(), FBType.Null()]), avatarUrl: FBType.String(), + isVerified: FBType.Boolean(), isMe: FBType.Boolean(), }); diff --git a/src/http/types/schemas/billing/subscription.schema.ts b/src/http/types/schemas/billing/subscription.schema.ts new file mode 100644 index 00000000..aacbbe2b --- /dev/null +++ b/src/http/types/schemas/billing/subscription.schema.ts @@ -0,0 +1,36 @@ +import { type Static, Type } from "@fastify/type-provider-typebox"; +import { SubscriptionStatus } from "@core/domain/enums"; +import { ResponseSchema } from "../create-response-schema"; + +/** + * What the client needs to render the subscription screen. + * + * State, never receipts or amounts: the store owns those and shows them to the + * user itself, and the client needs none of it to choose between "subscribe", + * "you are subscribed" and "your subscription ends on the 14th". + */ +export const SubscriptionResponseSchema = ResponseSchema( + Type.Object({ + /** Whether the badge is currently granted. */ + isVerified: Type.Boolean(), + + /** When it expires, null when nothing is granted. */ + verifiedUntil: Type.Union([ + Type.String({ format: "date-time" }), + Type.Null(), + ]), + + /** Null for an account that has never subscribed. */ + status: Type.Union([Type.Enum(SubscriptionStatus), Type.Null()]), + + currentPeriodEnd: Type.Union([ + Type.String({ format: "date-time" }), + Type.Null(), + ]), + + /** The user cancelled, but the period they paid for is still running. */ + cancelAtPeriodEnd: Type.Boolean(), + }), +); + +export type SubscriptionResponse = Static; diff --git a/src/http/types/schemas/block/block.schema.ts b/src/http/types/schemas/block/block.schema.ts index 81775ede..2c737672 100644 --- a/src/http/types/schemas/block/block.schema.ts +++ b/src/http/types/schemas/block/block.schema.ts @@ -20,6 +20,7 @@ export const BlockedUserItemSchema = FBType.Object({ username: FBType.String(), fullName: FBType.String(), avatarUrl: FBType.String(), + isVerified: FBType.Boolean(), bio: FBType.Union([FBType.String(), FBType.Null()]), }); diff --git a/src/http/types/schemas/comment/get-comment.schema.ts b/src/http/types/schemas/comment/get-comment.schema.ts index c4f26c81..00c69e98 100644 --- a/src/http/types/schemas/comment/get-comment.schema.ts +++ b/src/http/types/schemas/comment/get-comment.schema.ts @@ -8,6 +8,7 @@ export const CommentAuthorSchema = FBType.Object({ username: FBType.String(), fullName: FBType.Optional(FBType.String()), avatarUrl: FBType.String(), + isVerified: FBType.Boolean(), isMe: FBType.Boolean(), }); diff --git a/src/http/types/schemas/conversation/conversation.schema.ts b/src/http/types/schemas/conversation/conversation.schema.ts index 229c800e..84c9b3f2 100644 --- a/src/http/types/schemas/conversation/conversation.schema.ts +++ b/src/http/types/schemas/conversation/conversation.schema.ts @@ -20,6 +20,7 @@ export const ConversationItemSchema = FBType.Object({ username: FBType.String(), fullName: FBType.Optional(FBType.String()), avatarUrl: FBType.String(), + isVerified: FBType.Boolean(), }), unreadCount: FBType.Number(), lastMessagePreview: FBType.Union([FBType.String(), FBType.Null()]), diff --git a/src/http/types/schemas/env.schema.ts b/src/http/types/schemas/env.schema.ts index c5375091..b0ca9a18 100644 --- a/src/http/types/schemas/env.schema.ts +++ b/src/http/types/schemas/env.schema.ts @@ -261,6 +261,17 @@ export const EnvSchema = Type.Object({ DEVICE_RETENTION_DAYS: Type.Number({ default: 90, minimum: 1 }), DEVICE_PURGE_CRON: Type.String({ default: "0 6 * * *" }), + // --- Verified badge --- + // The nightly repair for billing state that drifted. It is also the only + // thing that notices a ban, since those are applied by hand in SQL and have + // no code path to hook - so the promise that a suspended account stops + // being charged rests on this schedule running. + SUBSCRIPTION_RECONCILE_CRON: Type.String({ default: "0 3 * * *" }), + SUBSCRIPTION_RECONCILE_BATCH_SIZE: Type.Number({ + default: 500, + minimum: 1, + }), + // --- Mobile clients --- // A web client is whatever was served this morning; an app version lives on // phones for months. These let the API tell a build that it is too old to diff --git a/src/http/types/schemas/notification/get-notification.schema.ts b/src/http/types/schemas/notification/get-notification.schema.ts index 362dfe80..e79f2339 100644 --- a/src/http/types/schemas/notification/get-notification.schema.ts +++ b/src/http/types/schemas/notification/get-notification.schema.ts @@ -10,6 +10,7 @@ const NotificationItemSchema = FBType.Object({ username: FBType.Optional(FBType.String()), type: FBType.Enum(NotificationType), avatarUrl: FBType.Optional(FBType.String()), + isVerified: FBType.Boolean(), // The most specific target id, kept for clients written against the old // shape. New clients should read the explicit ids below. referenceId: FBType.Optional(FBType.String()), diff --git a/src/http/types/schemas/post/get-post.schema.ts b/src/http/types/schemas/post/get-post.schema.ts index 5320f914..383a26bd 100644 --- a/src/http/types/schemas/post/get-post.schema.ts +++ b/src/http/types/schemas/post/get-post.schema.ts @@ -7,6 +7,7 @@ export const PostAuthorSchema = FBType.Object({ id: FBType.String({ format: "uuid" }), username: FBType.String(), avatarUrl: FBType.String(), + isVerified: FBType.Boolean(), fullName: FBType.Union([FBType.String(), FBType.Null()]), isMe: FBType.Optional(FBType.Boolean()), }); diff --git a/src/http/types/schemas/profile/bot-profiles.schema.ts b/src/http/types/schemas/profile/bot-profiles.schema.ts index 4645a074..f04c77b5 100644 --- a/src/http/types/schemas/profile/bot-profiles.schema.ts +++ b/src/http/types/schemas/profile/bot-profiles.schema.ts @@ -25,6 +25,7 @@ export const BotProfileItemSchema = FBType.Object({ username: FBType.String(), fullName: FBType.String(), avatarUrl: FBType.String(), + isVerified: FBType.Boolean(), bannerUrl: FBType.String(), bio: FBType.Union([FBType.String(), FBType.Null()]), categories: FBType.Array(FBType.Enum(PostCategory)), diff --git a/src/http/types/schemas/profile/followers.schema.ts b/src/http/types/schemas/profile/followers.schema.ts index f9c8f5b3..67fe52e6 100644 --- a/src/http/types/schemas/profile/followers.schema.ts +++ b/src/http/types/schemas/profile/followers.schema.ts @@ -6,6 +6,7 @@ export const FollowListItemSchema = FBType.Object({ username: FBType.String(), fullName: FBType.String(), avatarUrl: FBType.String(), + isVerified: FBType.Boolean(), bio: FBType.Union([FBType.String(), FBType.Null()]), isFollowing: FBType.Boolean(), isMe: FBType.Boolean(), diff --git a/src/http/types/schemas/profile/get-profile.schema.ts b/src/http/types/schemas/profile/get-profile.schema.ts index fb02ad3e..fb37944e 100644 --- a/src/http/types/schemas/profile/get-profile.schema.ts +++ b/src/http/types/schemas/profile/get-profile.schema.ts @@ -9,6 +9,7 @@ export const ProfileItemSchema = FBType.Object({ bio: FBType.Union([FBType.String(), FBType.Null()]), location: FBType.Union([FBType.String(), FBType.Null()]), avatarUrl: FBType.String(), + isVerified: FBType.Boolean(), bannerUrl: FBType.String(), socials: FBType.Record(FBType.String(), FBType.String()), categories: FBType.Array(FBType.Enum(PostCategory)), diff --git a/src/http/types/schemas/profile/search-profile.schema.ts b/src/http/types/schemas/profile/search-profile.schema.ts index b59a7660..fd28f7b8 100644 --- a/src/http/types/schemas/profile/search-profile.schema.ts +++ b/src/http/types/schemas/profile/search-profile.schema.ts @@ -20,6 +20,7 @@ export const SearchProfileItemSchema = FBType.Object({ bio: FBType.Union([FBType.String(), FBType.Null()]), location: FBType.Union([FBType.String(), FBType.Null()]), avatarUrl: FBType.String(), + isVerified: FBType.Boolean(), bannerUrl: FBType.String(), socials: FBType.Record(FBType.String(), FBType.String()), categories: FBType.Array(FBType.Enum(PostCategory)), diff --git a/src/http/types/schemas/profile/suggested-users.schema.ts b/src/http/types/schemas/profile/suggested-users.schema.ts index 4e334c15..54c69f66 100644 --- a/src/http/types/schemas/profile/suggested-users.schema.ts +++ b/src/http/types/schemas/profile/suggested-users.schema.ts @@ -7,6 +7,7 @@ export const SuggestedUserItemSchema = FBType.Object({ username: FBType.String(), fullName: FBType.String(), avatarUrl: FBType.String(), + isVerified: FBType.Boolean(), bannerUrl: FBType.String(), bio: FBType.Union([FBType.String(), FBType.Null()]), followersCount: FBType.Number(), diff --git a/src/infrastructure/external/billing/noop-billing.service.ts b/src/infrastructure/external/billing/noop-billing.service.ts new file mode 100644 index 00000000..9f0fb140 --- /dev/null +++ b/src/infrastructure/external/billing/noop-billing.service.ts @@ -0,0 +1,44 @@ +import type { + BillingPort, + ProviderSubscription, +} from "@core/ports/services/billing.port"; + +/** + * A billing provider that knows nothing, for deployments with no store behind + * them. + * + * The counterpart of `NoopModerationService` and `NoopPushService`. Everything + * above the port works without a provider: subscriptions can be read, badges + * expire on their own, the reconcile pass runs and finds nothing to repair. + * What cannot happen is a purchase, which is correct - there is nowhere to + * make one. + * + * Note what it does *not* do: it 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 provider. + */ +export class NoopBillingService implements BillingPort { + /** + * Reports that nothing is known about the subscription. + * + * The reconcile pass reads this as "the provider cannot say" and leaves + * the row alone, rather than as "it ended". + * + * @returns Null, always. + */ + fetchSubscription(): Promise { + return Promise.resolve(null); + } + + /** + * Accepts a cancellation there is nothing to cancel. + * + * True rather than false: the caller's question is whether this account is + * still being charged, and with no provider the answer is no. + * + * @returns True, always. + */ + cancelSubscription(): Promise { + return Promise.resolve(true); + } +} diff --git a/src/infrastructure/jobs/billing/subscription-reconcile.job.ts b/src/infrastructure/jobs/billing/subscription-reconcile.job.ts new file mode 100644 index 00000000..d894d85e --- /dev/null +++ b/src/infrastructure/jobs/billing/subscription-reconcile.job.ts @@ -0,0 +1,26 @@ +import type { + ReconcileSubscriptionsOutput, + ReconcileSubscriptionsUseCase, +} from "@core/use-cases/billing/reconcile-subscriptions"; + +/** + * Runs one subscription reconcile pass. + */ +export class SubscriptionReconcileJob { + /** + * @param reconcileSubscriptionsUseCase - The use case that does the work + */ + constructor( + private readonly reconcileSubscriptionsUseCase: ReconcileSubscriptionsUseCase, + ) {} + + /** + * Executes the pass. + * + * @param limit - Most rows to examine + * @returns What the pass did + */ + async run(limit: number): Promise { + return this.reconcileSubscriptionsUseCase.execute(limit); + } +} diff --git a/src/infrastructure/jobs/billing/subscription-reconcile.scheduler.ts b/src/infrastructure/jobs/billing/subscription-reconcile.scheduler.ts new file mode 100644 index 00000000..9d14dcdb --- /dev/null +++ b/src/infrastructure/jobs/billing/subscription-reconcile.scheduler.ts @@ -0,0 +1,86 @@ +import type { FastifyBaseLogger } from "fastify"; +import cron, { type ScheduledTask } from "node-cron"; +import type { SubscriptionReconcileJob } from "./subscription-reconcile.job"; + +export interface SubscriptionReconcileSchedulerOptions { + cronExpression: string; + batchSize: number; +} + +/** + * Repairs billing state on a cron schedule. + * + * Passes no timezone: nothing here has to land at a particular hour for a + * reader. What it must do is run every day, because it is the only thing that + * notices a ban - those are applied by hand in SQL and have no code path to + * hook - and the promise that a suspended account stops being charged rests + * on it. + */ +export class SubscriptionReconcileScheduler { + private task?: ScheduledTask; + + /** True while a pass is in flight; node-cron does not await the callback. */ + private running = false; + + /** + * @param job - The job to run on each tick + * @param options - Schedule and batch size + * @param logger - Fastify logger + */ + constructor( + private readonly job: SubscriptionReconcileJob, + private readonly options: SubscriptionReconcileSchedulerOptions, + private readonly logger: FastifyBaseLogger, + ) {} + + /** + * Starts the schedule. Calling it twice is a no-op. + */ + start(): void { + if (this.task) return; + + this.task = cron.schedule(this.options.cronExpression, () => { + void (async (): Promise => { + if (this.running) { + this.logger.warn( + { job: "subscription-reconcile" }, + "Skipping a reconcile tick: the previous pass is still running", + ); + return; + } + + this.running = true; + + try { + const result = await this.job.run(this.options.batchSize); + + this.logger.info( + { + job: "subscription-reconcile", + ...result, + cronExpression: this.options.cronExpression, + }, + "Subscription reconcile completed", + ); + } catch (error) { + this.logger.error( + { job: "subscription-reconcile", error }, + "Subscription reconcile failed", + ); + } finally { + this.running = false; + } + })(); + }); + + this.logger.info("Subscription Reconcile Scheduler initialized"); + } + + /** + * Stops the schedule. + */ + stop(): void { + if (!this.task) return; + this.task = undefined; + } +} diff --git a/src/infrastructure/persistence/mappers/article-prisma.mapper.ts b/src/infrastructure/persistence/mappers/article-prisma.mapper.ts index 1b9b7f1d..8db3b887 100644 --- a/src/infrastructure/persistence/mappers/article-prisma.mapper.ts +++ b/src/infrastructure/persistence/mappers/article-prisma.mapper.ts @@ -1,4 +1,5 @@ import { Article } from "@core/domain/entities/article.entity"; +import { isVerified } from "@core/use-cases/shared/verification/is-verified"; import type { ArticleStatus } from "@core/domain/enums"; import type { PostCategory } from "@core/domain/enums/post-category-enum"; import type { MentionedUser } from "@core/domain/interfaces/mentioned-user.interface"; @@ -10,6 +11,7 @@ export type ArticleWithRelations = Prisma.ArticleGetPayload<{ select: { id: true; username: true; + verifiedUntil: true; profile: { select: { avatarUrl: true; fullName: true } }; }; }; @@ -46,6 +48,7 @@ export interface ArticleResponse { avatarUrl: string; fullName: string | null; isMe: boolean; + isVerified: boolean; }; tags: { name: string }[]; /** Users named with an @handle in the body, resolved at write time. */ @@ -98,6 +101,7 @@ export class ArticlePrismaMapper { username: dbArticle.author.username, avatarUrl: dbArticle.author?.profile?.avatarUrl ?? undefined, fullName: dbArticle.author?.profile?.fullName ?? undefined, + isVerified: isVerified(dbArticle.author.verifiedUntil), }, tags: dbArticle.tags?.map((tag) => tag.name) ?? [], mentions: dbArticle.mentionedUsers ?? [], @@ -202,6 +206,7 @@ export class ArticlePrismaMapper { author: { id: article.author.id, username, + isVerified: article.author.isVerified ?? false, avatarUrl: article.author.avatarUrl ? article.author.avatarUrl.startsWith("http") ? article.author.avatarUrl diff --git a/src/infrastructure/persistence/mappers/comment-prisma.mapper.ts b/src/infrastructure/persistence/mappers/comment-prisma.mapper.ts index d3a6e350..4ef114df 100644 --- a/src/infrastructure/persistence/mappers/comment-prisma.mapper.ts +++ b/src/infrastructure/persistence/mappers/comment-prisma.mapper.ts @@ -1,4 +1,5 @@ import type { Prisma } from "@generated/prisma/client"; +import { isVerified } from "@core/use-cases/shared/verification/is-verified"; import { Comment } from "@core/domain/entities/comment.entity"; import { MediaModerationStatus } from "@core/domain/enums"; import type { MentionedUser } from "@core/domain/interfaces/mentioned-user.interface"; @@ -9,6 +10,7 @@ export type CommentWithRelations = Prisma.CommentGetPayload<{ select: { id: true; username: true; + verifiedUntil: true; profile: { select: { avatarUrl: true; fullName: true } }; }; }; @@ -47,6 +49,7 @@ export interface CommentResponse { fullName?: string; avatarUrl: string; isMe: boolean; + isVerified: boolean; }; likeCount: number; replyCount: number; @@ -82,6 +85,7 @@ export class CommentPrismaMapper { username: dbComment.author.username, avatarUrl: dbComment.author?.profile?.avatarUrl ?? undefined, fullName: dbComment.author?.profile?.fullName ?? undefined, + isVerified: isVerified(dbComment.author.verifiedUntil), }, likeCount: dbComment.likeCount, replyCount: dbComment.replyCount, @@ -137,6 +141,7 @@ export class CommentPrismaMapper { isMe: currentUserId ? comment.authorId === currentUserId : false, + isVerified: author.isVerified ?? false, }, }; } diff --git a/src/infrastructure/persistence/mappers/conversation-prisma.mapper.ts b/src/infrastructure/persistence/mappers/conversation-prisma.mapper.ts index 9c1fd0da..f0a9457d 100644 --- a/src/infrastructure/persistence/mappers/conversation-prisma.mapper.ts +++ b/src/infrastructure/persistence/mappers/conversation-prisma.mapper.ts @@ -1,4 +1,5 @@ import type { Prisma } from "@generated/prisma/client"; +import { isVerified } from "@core/use-cases/shared/verification/is-verified"; import { Conversation } from "@core/domain/entities/conversation.entity"; import type { ConversationStatus } from "@core/domain/enums"; @@ -12,6 +13,7 @@ import type { ConversationStatus } from "@core/domain/enums"; export const conversationParticipantSelect = { id: true, username: true, + verifiedUntil: true, profile: { select: { avatarUrl: true, fullName: true } }, } as const; @@ -40,6 +42,7 @@ export interface ConversationResponse { username: string; fullName?: string; avatarUrl: string; + isVerified: boolean; }; /** How many messages the reader has not seen. */ @@ -86,6 +89,7 @@ export class ConversationPrismaMapper { username: user.username, fullName: user.profile?.fullName ?? undefined, avatarUrl: user.profile?.avatarUrl ?? undefined, + isVerified: isVerified(user.verifiedUntil), })), createdAt: record.createdAt, updatedAt: record.updatedAt, @@ -144,6 +148,7 @@ export class ConversationPrismaMapper { username: other.username, fullName: other.fullName, avatarUrl: this.resolveAvatarUrl(other.avatarUrl, cdnUrl), + isVerified: other.isVerified ?? false, }, unreadCount: conversation.unreadFor(viewerId), lastMessagePreview: conversation.lastMessagePreview, diff --git a/src/infrastructure/persistence/mappers/notification-prisma.mapper.ts b/src/infrastructure/persistence/mappers/notification-prisma.mapper.ts index 72f9cb2a..56eb7249 100644 --- a/src/infrastructure/persistence/mappers/notification-prisma.mapper.ts +++ b/src/infrastructure/persistence/mappers/notification-prisma.mapper.ts @@ -1,4 +1,5 @@ import type { NotificationType } from "@generated/prisma/client"; +import { isVerified } from "@core/use-cases/shared/verification/is-verified"; import type { NotificationType as CoreNotificationType } from "@core/domain/enums/notification-type.enum"; import { Notification } from "@core/domain/entities/notification.entity"; @@ -15,6 +16,7 @@ export interface PrismaNotificationItem { isRead: boolean; issuer: { username: string; + verifiedUntil: Date | null; profile: { avatarUrl: string; } | null; @@ -45,6 +47,7 @@ export class NotificationPrismaMapper { commentId: item.commentId || undefined, articleSlug: item.article?.slug, username: item.issuer.username, + isVerified: isVerified(item.issuer.verifiedUntil), avatarUrl: item.issuer.profile?.avatarUrl ?? "", createdAt: item.createdAt, isRead: item.isRead, @@ -75,10 +78,12 @@ export class NotificationPrismaMapper { articleSlug?: string; commentId?: string; username: string; + isVerified: boolean; isRead: boolean; } { return { id: notification.id, + isVerified: notification.isVerified, avatarUrl: notification.avatarUrl ? notification.avatarUrl.startsWith("http") ? notification.avatarUrl diff --git a/src/infrastructure/persistence/mappers/post-prisma.mapper.ts b/src/infrastructure/persistence/mappers/post-prisma.mapper.ts index c1f02f0b..664c9a59 100644 --- a/src/infrastructure/persistence/mappers/post-prisma.mapper.ts +++ b/src/infrastructure/persistence/mappers/post-prisma.mapper.ts @@ -1,4 +1,5 @@ import { Post } from "@core/domain/entities/post.entity"; +import { isVerified } from "@core/use-cases/shared/verification/is-verified"; import type { PostCategory } from "@core/domain/enums/post-category-enum"; import { MediaModerationStatus } from "@core/domain/enums/media-moderation-status.enum"; import type { PostType } from "@core/domain/enums/post-type.enum"; @@ -11,6 +12,7 @@ export type PostWithRelations = Prisma.PostGetPayload<{ select: { id: true; username: true; + verifiedUntil: true; profile: { select: { avatarUrl: true; fullName: true } }; }; }; @@ -24,6 +26,7 @@ export type PostWithRelations = Prisma.PostGetPayload<{ select: { id: true; username: true; + verifiedUntil: true; profile: { select: { avatarUrl: true; fullName: true }; }; @@ -55,6 +58,7 @@ export interface QuotedPostResponse { username: string; avatarUrl: string; fullName: string | null; + isVerified: boolean; }; } @@ -73,6 +77,7 @@ export interface PostResponse { avatarUrl: string; isMe?: boolean; fullName: string | null; + isVerified: boolean; }; isLiked: boolean; isBookmarked: boolean; @@ -112,6 +117,7 @@ export class PostPrismaMapper { username: dbPost.author.username, avatarUrl: dbPost.author?.profile?.avatarUrl ?? undefined, fullName: dbPost.author?.profile?.fullName ?? undefined, + isVerified: isVerified(dbPost.author.verifiedUntil), }, tags: dbPost.tags?.map((t) => t.name) || [], @@ -146,6 +152,9 @@ export class PostPrismaMapper { fullName: dbPost.quotedPost.author?.profile?.fullName ?? undefined, + isVerified: isVerified( + dbPost.quotedPost.author.verifiedUntil, + ), }, } : undefined, @@ -228,6 +237,7 @@ export class PostPrismaMapper { avatarUrl: this.resolveAvatarUrl(post.author.avatarUrl, cdnUrl), fullName: post.author.fullName ?? null, isMe: currentUserId ? post.author.id === currentUserId : false, + isVerified: post.author.isVerified ?? false, }, tags: post.tags?.map((t) => ({ name: t })) || [], mentions: post.mentions, @@ -277,6 +287,7 @@ export class PostPrismaMapper { cdnUrl, ), fullName: quoted.author.fullName ?? null, + isVerified: quoted.author.isVerified ?? false, }, }; } diff --git a/src/infrastructure/persistence/mappers/profile-prisma.mapper.ts b/src/infrastructure/persistence/mappers/profile-prisma.mapper.ts index 6e757d6e..2645e686 100644 --- a/src/infrastructure/persistence/mappers/profile-prisma.mapper.ts +++ b/src/infrastructure/persistence/mappers/profile-prisma.mapper.ts @@ -1,4 +1,5 @@ import { Profile } from "@core/domain/entities/profile.entity"; +import { isVerified } from "@core/use-cases/shared/verification/is-verified"; import type { PostCategory } from "@core/domain/enums/post-category-enum"; import type { UpdateProfileInput } from "@core/use-cases/profile/update-profil/update-profile-usecase.input"; import type { @@ -9,6 +10,7 @@ import type { type PrismaProfileWithUserAndCounts = PrismaProfile & { user?: { username: string; + verifiedUntil?: Date | null; _count?: { followers: number; following: number; @@ -33,6 +35,7 @@ export class ProfilePrismaMapper { userId: dbProfile.userId, username: dbProfile.user?.username || "unknown", + isVerified: isVerified(dbProfile.user?.verifiedUntil), fullName: dbProfile.fullName, bio: dbProfile.bio, location: dbProfile.location, @@ -81,6 +84,7 @@ export class ProfilePrismaMapper { location: string | null; avatarUrl: string; bannerUrl: string; + isVerified: boolean; socials: Record; categories: PostCategory[]; languages: string[]; @@ -97,6 +101,7 @@ export class ProfilePrismaMapper { location: profile.location, avatarUrl: profile.avatarUrl, bannerUrl: profile.bannerUrl, + isVerified: profile.isVerified, socials: profile.socials, categories: profile.categories, languages: profile.languages, diff --git a/src/infrastructure/persistence/mappers/subscription-prisma.mapper.ts b/src/infrastructure/persistence/mappers/subscription-prisma.mapper.ts new file mode 100644 index 00000000..85920696 --- /dev/null +++ b/src/infrastructure/persistence/mappers/subscription-prisma.mapper.ts @@ -0,0 +1,63 @@ +import type { Subscription as PrismaSubscription } from "@generated/prisma/client"; +import { Subscription } from "@core/domain/entities/subscription.entity"; +import type { BillingProvider, SubscriptionStatus } from "@core/domain/enums"; + +/** + * Two-way mapper between the `subscriptions` table and the domain entity. + * + * There is no `toResponse`. What the API serves is `GetSubscriptionOutput`, + * which is a smaller thing on purpose: provider identifiers are for talking to + * the store, not for handing to a client. + */ +export class SubscriptionPrismaMapper { + /** + * Maps a database row to the domain entity. + * + * @param row - The Prisma subscription row + * @returns The instantiated Subscription domain entity + */ + public static toDomain(row: PrismaSubscription): Subscription { + return Subscription.with({ + id: row.id, + userId: row.userId, + provider: row.provider as unknown as BillingProvider, + providerCustomerId: row.providerCustomerId, + providerSubscriptionId: row.providerSubscriptionId, + status: row.status as unknown as SubscriptionStatus, + currentPeriodEnd: row.currentPeriodEnd, + cancelAtPeriodEnd: row.cancelAtPeriodEnd, + lastEventAt: row.lastEventAt, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }); + } + + /** + * The fields a save writes, whether the row exists or not. + * + * Shared between both halves of the upsert so a resubscription cannot + * leave a stale status or period end from the previous one. + * + * @param subscription - The state being stored + * @returns The fields to write + */ + public static toPrismaData(subscription: Subscription): { + provider: string; + providerCustomerId: string | null; + providerSubscriptionId: string | null; + status: string; + currentPeriodEnd: Date | null; + cancelAtPeriodEnd: boolean; + lastEventAt: Date | null; + } { + return { + provider: subscription.provider, + providerCustomerId: subscription.providerCustomerId, + providerSubscriptionId: subscription.providerSubscriptionId, + status: subscription.status, + currentPeriodEnd: subscription.currentPeriodEnd, + cancelAtPeriodEnd: subscription.cancelAtPeriodEnd, + lastEventAt: subscription.lastEventAt, + }; + } +} diff --git a/src/infrastructure/persistence/repositories/prisma-article.repository.ts b/src/infrastructure/persistence/repositories/prisma-article.repository.ts index c4920848..c8f28dd6 100644 --- a/src/infrastructure/persistence/repositories/prisma-article.repository.ts +++ b/src/infrastructure/persistence/repositories/prisma-article.repository.ts @@ -26,6 +26,7 @@ type ArticleRelationInclude = { select: { id: true; username: true; + verifiedUntil: true; profile: { select: { avatarUrl: true; fullName: true } }; }; }; @@ -66,6 +67,7 @@ export class PrismaArticleRepository implements IArticleRepository { select: { id: true, username: true, + verifiedUntil: true, profile: { select: { avatarUrl: true, fullName: true } }, }, }, diff --git a/src/infrastructure/persistence/repositories/prisma-block.repository.ts b/src/infrastructure/persistence/repositories/prisma-block.repository.ts index 7ef041de..d3130160 100644 --- a/src/infrastructure/persistence/repositories/prisma-block.repository.ts +++ b/src/infrastructure/persistence/repositories/prisma-block.repository.ts @@ -1,4 +1,5 @@ import type { PrismaTransactionalClient } from "@infrastructure/persistence/database/prisma-client.type"; +import { isVerified } from "@core/use-cases/shared/verification/is-verified"; import type { BlockedUserSummary, BlockPairState, @@ -152,6 +153,7 @@ export class PrismaBlockRepository implements IBlockRepository { select: { id: true, username: true, + verifiedUntil: true, profile: { select: { fullName: true, @@ -169,6 +171,7 @@ export class PrismaBlockRepository implements IBlockRepository { username: block.blocked.username, fullName: block.blocked.profile?.fullName || "", avatarUrl: block.blocked.profile?.avatarUrl || "", + isVerified: isVerified(block.blocked.verifiedUntil), bio: block.blocked.profile?.bio || null, })); } diff --git a/src/infrastructure/persistence/repositories/prisma-comment-bookmark.repository.ts b/src/infrastructure/persistence/repositories/prisma-comment-bookmark.repository.ts index 38d7efc1..f9e76c62 100644 --- a/src/infrastructure/persistence/repositories/prisma-comment-bookmark.repository.ts +++ b/src/infrastructure/persistence/repositories/prisma-comment-bookmark.repository.ts @@ -46,6 +46,7 @@ export class PrismaCommentBookmarkRepository implements ICommentBookmarkReposito select: { id: true, username: true, + verifiedUntil: true, profile: { select: { avatarUrl: true, diff --git a/src/infrastructure/persistence/repositories/prisma-comment.repository.ts b/src/infrastructure/persistence/repositories/prisma-comment.repository.ts index c76b304e..269ae48c 100644 --- a/src/infrastructure/persistence/repositories/prisma-comment.repository.ts +++ b/src/infrastructure/persistence/repositories/prisma-comment.repository.ts @@ -50,6 +50,7 @@ export class PrismaCommentRepository implements ICommentRepository { select: { id: true, username: true, + verifiedUntil: true, profile: { select: { avatarUrl: true, fullName: true }, }, @@ -81,6 +82,7 @@ export class PrismaCommentRepository implements ICommentRepository { select: { id: true, username: true, + verifiedUntil: true, profile: { select: { avatarUrl: true } }, }, }, @@ -138,6 +140,7 @@ export class PrismaCommentRepository implements ICommentRepository { select: { id: true, username: true, + verifiedUntil: true, profile: { select: { avatarUrl: true, fullName: true }, }, @@ -194,6 +197,7 @@ export class PrismaCommentRepository implements ICommentRepository { select: { id: true, username: true, + verifiedUntil: true, profile: { select: { avatarUrl: true, fullName: true }, }, diff --git a/src/infrastructure/persistence/repositories/prisma-follow.repository.ts b/src/infrastructure/persistence/repositories/prisma-follow.repository.ts index a381e86e..49fba2b7 100644 --- a/src/infrastructure/persistence/repositories/prisma-follow.repository.ts +++ b/src/infrastructure/persistence/repositories/prisma-follow.repository.ts @@ -1,4 +1,5 @@ import type { PrismaTransactionalClient } from "@infrastructure/persistence/database/prisma-client.type"; +import { isVerified } from "@core/use-cases/shared/verification/is-verified"; import type { IFollowRepository } from "@core/ports/repositories/follow.repository"; export class PrismaFollowUserRepository implements IFollowRepository { @@ -61,6 +62,7 @@ export class PrismaFollowUserRepository implements IFollowRepository { username: string; fullName: string; avatarUrl: string; + isVerified: boolean; bio: string | null; }[] > { @@ -74,6 +76,7 @@ export class PrismaFollowUserRepository implements IFollowRepository { select: { id: true, username: true, + verifiedUntil: true, profile: { select: { fullName: true, @@ -91,6 +94,7 @@ export class PrismaFollowUserRepository implements IFollowRepository { username: f.follower.username, fullName: f.follower.profile?.fullName || "", avatarUrl: f.follower.profile?.avatarUrl || "", + isVerified: isVerified(f.follower.verifiedUntil), bio: f.follower.profile?.bio || null, })); } @@ -105,6 +109,7 @@ export class PrismaFollowUserRepository implements IFollowRepository { username: string; fullName: string; avatarUrl: string; + isVerified: boolean; bio: string | null; }[] > { @@ -118,6 +123,7 @@ export class PrismaFollowUserRepository implements IFollowRepository { select: { id: true, username: true, + verifiedUntil: true, profile: { select: { fullName: true, @@ -135,6 +141,7 @@ export class PrismaFollowUserRepository implements IFollowRepository { username: f.following.username, fullName: f.following.profile?.fullName || "", avatarUrl: f.following.profile?.avatarUrl || "", + isVerified: isVerified(f.following.verifiedUntil), bio: f.following.profile?.bio || null, })); } diff --git a/src/infrastructure/persistence/repositories/prisma-notification.repository.ts b/src/infrastructure/persistence/repositories/prisma-notification.repository.ts index acdac00e..d7892feb 100644 --- a/src/infrastructure/persistence/repositories/prisma-notification.repository.ts +++ b/src/infrastructure/persistence/repositories/prisma-notification.repository.ts @@ -73,6 +73,7 @@ export class PrismaNotificationRepository implements INotificationRepository { issuer: { select: { username: true, + verifiedUntil: true, profile: { select: { avatarUrl: true, @@ -124,6 +125,7 @@ export class PrismaNotificationRepository implements INotificationRepository { issuer: { select: { username: true, + verifiedUntil: true, profile: { select: { avatarUrl: true, diff --git a/src/infrastructure/persistence/repositories/prisma-post.repository.ts b/src/infrastructure/persistence/repositories/prisma-post.repository.ts index 46902abe..2dea3ab0 100644 --- a/src/infrastructure/persistence/repositories/prisma-post.repository.ts +++ b/src/infrastructure/persistence/repositories/prisma-post.repository.ts @@ -26,6 +26,7 @@ const POST_AUTHOR_SELECT = { select: { id: true, username: true, + verifiedUntil: true, profile: { select: { avatarUrl: true, fullName: true } }, }, } as const; diff --git a/src/infrastructure/persistence/repositories/prisma-subscription.repository.ts b/src/infrastructure/persistence/repositories/prisma-subscription.repository.ts new file mode 100644 index 00000000..3720348b --- /dev/null +++ b/src/infrastructure/persistence/repositories/prisma-subscription.repository.ts @@ -0,0 +1,135 @@ +import type { Subscription } from "@core/domain/entities/subscription.entity"; +import { SubscriptionStatus } from "@core/domain/enums"; +import type { + ISubscriptionRepository, + ReconcilableSubscription, +} from "@core/ports/repositories/subscription.repository"; +import { SubscriptionPrismaMapper } from "@infrastructure/persistence/mappers/subscription-prisma.mapper"; +import type { PrismaTransactionalClient } from "@infrastructure/persistence/database/prisma-client.type"; +import type { + BillingProvider as PrismaBillingProvider, + SubscriptionStatus as PrismaSubscriptionStatus, +} from "@generated/prisma/client"; + +/** + * Statuses worth a nightly look. + * + * A revoked or long-cancelled row has nothing left to repair; including them + * would make the pass grow with every account that ever subscribed rather than + * with the ones that currently pay. + */ +const LIVE_STATUSES = [ + SubscriptionStatus.PENDING, + SubscriptionStatus.ACTIVE, + SubscriptionStatus.IN_GRACE, +] as unknown as PrismaSubscriptionStatus[]; + +/** + * Prisma implementation of the subscription repository. + */ +export class PrismaSubscriptionRepository implements ISubscriptionRepository { + /** + * @param prisma - Prisma client, possibly scoped to a transaction + */ + constructor(private readonly prisma: PrismaTransactionalClient) {} + + /** + * Reads an account's billing row. + * + * @param userId - The account to look up. + * @returns Its subscription, or null. + */ + async findByUserId(userId: string): Promise { + const row = await this.prisma.subscription.findUnique({ + where: { userId }, + }); + + return row ? SubscriptionPrismaMapper.toDomain(row) : null; + } + + /** + * Reads a billing row by the provider's identifier for it. + * + * @param providerSubscriptionId - The provider's identifier. + * @returns The subscription, or null. + */ + async findByProviderSubscriptionId( + providerSubscriptionId: string, + ): Promise { + const row = await this.prisma.subscription.findUnique({ + where: { providerSubscriptionId }, + }); + + return row ? SubscriptionPrismaMapper.toDomain(row) : null; + } + + /** + * Writes an account's billing row, creating it if there is none. + * + * @param subscription - The state to store. + * @returns The stored subscription. + */ + async save(subscription: Subscription): Promise { + const data = SubscriptionPrismaMapper.toPrismaData(subscription); + + const row = await this.prisma.subscription.upsert({ + where: { userId: subscription.userId }, + update: { + ...data, + provider: data.provider as PrismaBillingProvider, + status: data.status as PrismaSubscriptionStatus, + }, + create: { + userId: subscription.userId, + ...data, + provider: data.provider as PrismaBillingProvider, + status: data.status as PrismaSubscriptionStatus, + }, + }); + + return SubscriptionPrismaMapper.toDomain(row); + } + + /** + * Sets the account's badge expiry. + * + * @param userId - The account whose badge is being set. + * @param verifiedUntil - When it expires, or null to remove it. + */ + async setVerifiedUntil( + userId: string, + verifiedUntil: Date | null, + ): Promise { + await this.prisma.user.update({ + where: { id: userId }, + data: { verifiedUntil }, + }); + } + + /** + * Reads the subscriptions the nightly reconcile has to look at. + * + * The owner's ban and deletion flags come back with the row because they + * are the reason half of this pass exists, and fetching them per row would + * turn one query into a hundred. + * + * @param limit - Most rows to return in one pass. + * @returns The subscriptions to reconcile. + */ + async findReconcilable(limit: number): Promise { + const rows = await this.prisma.subscription.findMany({ + where: { status: { in: LIVE_STATUSES } }, + orderBy: { updatedAt: "asc" }, + take: limit, + include: { + user: { select: { bannedAt: true, deletedAt: true } }, + }, + }); + + return rows.map((row) => ({ + subscription: SubscriptionPrismaMapper.toDomain(row), + isBanned: row.user.bannedAt !== null, + isDeleted: row.user.deletedAt !== null, + })); + } +} diff --git a/tests/unit/core/domain/entities/subscription.entity.test.ts b/tests/unit/core/domain/entities/subscription.entity.test.ts new file mode 100644 index 00000000..91fb4dd7 --- /dev/null +++ b/tests/unit/core/domain/entities/subscription.entity.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { Subscription } from "@core/domain/entities/subscription.entity"; +import { BillingProvider, SubscriptionStatus } from "@core/domain/enums"; +import { isVerified } from "@core/use-cases/shared/verification/is-verified"; + +const PERIOD_END = new Date("2026-12-01T00:00:00Z"); + +function build( + status: SubscriptionStatus, + overrides: { currentPeriodEnd?: Date | null; lastEventAt?: Date | null } = {}, +): Subscription { + return Subscription.with({ + id: "sub-1", + userId: "user-1", + provider: BillingProvider.GOOGLE_PLAY, + providerSubscriptionId: "gp-1", + status, + currentPeriodEnd: + overrides.currentPeriodEnd === undefined + ? PERIOD_END + : overrides.currentPeriodEnd, + lastEventAt: overrides.lastEventAt ?? null, + }); +} + +describe("Subscription.entitlementUntil", () => { + it("should entitle an active subscription until its period ends", () => { + expect(build(SubscriptionStatus.ACTIVE).entitlementUntil()).toEqual( + PERIOD_END, + ); + }); + + it("should keep entitling while a failed payment is being retried", () => { + // The user paid for the period they are in; taking the badge away the + // moment a card is declined punishes an expired card, not a decision + // to stop paying. + expect(build(SubscriptionStatus.IN_GRACE).entitlementUntil()).toEqual( + PERIOD_END, + ); + }); + + it("should entitle nothing for pending, cancelled or revoked", () => { + for (const status of [ + SubscriptionStatus.PENDING, + SubscriptionStatus.CANCELED, + SubscriptionStatus.REVOKED, + ]) { + expect(build(status).entitlementUntil()).toBeNull(); + } + }); + + it("should entitle nothing when there is no period at all", () => { + expect( + build(SubscriptionStatus.ACTIVE, { + currentPeriodEnd: null, + }).entitlementUntil(), + ).toBeNull(); + }); + + it("should report entitlement against a clock", () => { + const subscription = build(SubscriptionStatus.ACTIVE); + + expect(subscription.isEntitled(new Date("2026-11-30T00:00:00Z"))).toBe( + true, + ); + expect(subscription.isEntitled(new Date("2026-12-02T00:00:00Z"))).toBe( + false, + ); + }); +}); + +describe("Subscription.accepts", () => { + const lastEventAt = new Date("2026-06-01T12:00:00Z"); + + it("should accept an event newer than the one it was built from", () => { + expect( + build(SubscriptionStatus.ACTIVE, { lastEventAt }).accepts( + new Date("2026-06-01T12:00:01Z"), + ), + ).toBe(true); + }); + + it("should refuse an older event", () => { + // Store notifications are not ordered, and each carries the whole + // state: an older one applied later would reinstate a subscription + // that has ended. + expect( + build(SubscriptionStatus.ACTIVE, { lastEventAt }).accepts( + new Date("2026-05-01T12:00:00Z"), + ), + ).toBe(false); + }); + + it("should accept a redelivery of the same event", () => { + expect( + build(SubscriptionStatus.ACTIVE, { lastEventAt }).accepts( + lastEventAt, + ), + ).toBe(true); + }); + + it("should accept anything when nothing has been applied yet", () => { + expect( + build(SubscriptionStatus.ACTIVE).accepts(new Date("2020-01-01")), + ).toBe(true); + }); + + it("should accept an undated event", () => { + // A provider that does not date its notifications leaves nothing to + // compare; refusing them all would mean never updating anything. + expect( + build(SubscriptionStatus.ACTIVE, { lastEventAt }).accepts(null), + ).toBe(true); + }); +}); + +describe("isVerified", () => { + it("should read an expiry in the future as verified", () => { + expect(isVerified(new Date(Date.now() + 60_000))).toBe(true); + }); + + it("should read a passed expiry as not verified", () => { + expect(isVerified(new Date(Date.now() - 60_000))).toBe(false); + }); + + it("should read an absent expiry as not verified", () => { + expect(isVerified(null)).toBe(false); + expect(isVerified(undefined)).toBe(false); + }); +}); diff --git a/tests/unit/core/use-cases/billing/subscription.usecase.test.ts b/tests/unit/core/use-cases/billing/subscription.usecase.test.ts new file mode 100644 index 00000000..03e762ce --- /dev/null +++ b/tests/unit/core/use-cases/billing/subscription.usecase.test.ts @@ -0,0 +1,284 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SyncSubscriptionUseCase } from "@core/use-cases/billing/sync-subscription"; +import { RevokeSubscriptionUseCase } from "@core/use-cases/billing/revoke-subscription"; +import { ReconcileSubscriptionsUseCase } from "@core/use-cases/billing/reconcile-subscriptions"; +import { Subscription } from "@core/domain/entities/subscription.entity"; +import { BillingProvider, SubscriptionStatus } from "@core/domain/enums"; +import type { ISubscriptionRepository } from "@core/ports/repositories/subscription.repository"; +import type { BillingPort } from "@core/ports/services/billing.port"; +import type { LoggerPort } from "@core/ports/services/logger.port"; + +const PERIOD_END = new Date("2026-12-01T00:00:00Z"); + +function stored(overrides: Partial[0]> = {}) { + return Subscription.with({ + id: "sub-1", + userId: "user-1", + provider: BillingProvider.GOOGLE_PLAY, + providerSubscriptionId: "gp-1", + status: SubscriptionStatus.ACTIVE, + currentPeriodEnd: PERIOD_END, + ...overrides, + }); +} + +function repository(): ISubscriptionRepository { + return { + findByUserId: vi.fn().mockResolvedValue(null), + findByProviderSubscriptionId: vi.fn().mockResolvedValue(null), + save: vi.fn().mockImplementation((s: Subscription) => Promise.resolve(s)), + setVerifiedUntil: vi.fn().mockResolvedValue(undefined), + findReconcilable: vi.fn().mockResolvedValue([]), + }; +} + +function logger(): LoggerPort { + return { error: vi.fn(), warn: vi.fn(), info: vi.fn() } as unknown as LoggerPort; +} + +describe("SyncSubscriptionUseCase", () => { + let repo: ISubscriptionRepository; + let useCase: SyncSubscriptionUseCase; + + const state = { + providerSubscriptionId: "gp-1", + status: SubscriptionStatus.ACTIVE, + currentPeriodEnd: PERIOD_END, + eventAt: new Date("2026-11-01T00:00:00Z"), + }; + + beforeEach(() => { + repo = repository(); + useCase = new SyncSubscriptionUseCase(repo, logger()); + }); + + const input = { + userId: "user-1", + provider: BillingProvider.GOOGLE_PLAY, + state, + }; + + it("should grant the badge for the period the provider reports", async () => { + const result = await useCase.execute(input); + + expect(result.applied).toBe(true); + expect(result.verifiedUntil).toEqual(PERIOD_END); + expect(repo.setVerifiedUntil).toHaveBeenCalledWith( + "user-1", + PERIOD_END, + ); + }); + + it("should refuse a purchase already claimed by another account", async () => { + // One receipt, one badge. Moving it would let a shared purchase grant + // the badge to whoever presents it last. + vi.mocked(repo.findByProviderSubscriptionId).mockResolvedValue( + stored({ userId: "somebody-else" }), + ); + + const result = await useCase.execute(input); + + expect(result).toMatchObject({ + applied: false, + reason: "claimed-by-another-account", + }); + expect(repo.save).not.toHaveBeenCalled(); + expect(repo.setVerifiedUntil).not.toHaveBeenCalled(); + }); + + it("should ignore an event older than the one already applied", async () => { + vi.mocked(repo.findByProviderSubscriptionId).mockResolvedValue( + stored({ lastEventAt: new Date("2026-11-15T00:00:00Z") }), + ); + + const result = await useCase.execute(input); + + expect(result).toMatchObject({ applied: false, reason: "stale-event" }); + expect(repo.save).not.toHaveBeenCalled(); + }); + + it("should clear the badge when the provider says it ended", async () => { + vi.mocked(repo.findByProviderSubscriptionId).mockResolvedValue(stored()); + + const result = await useCase.execute({ + ...input, + state: { ...state, status: SubscriptionStatus.CANCELED }, + }); + + expect(result.verifiedUntil).toBeNull(); + expect(repo.setVerifiedUntil).toHaveBeenCalledWith("user-1", null); + }); + + it("should keep the provider customer id when an update omits it", async () => { + vi.mocked(repo.findByProviderSubscriptionId).mockResolvedValue( + stored({ providerCustomerId: "cust-1" }), + ); + + await useCase.execute(input); + + const saved = vi.mocked(repo.save).mock.calls[0]![0]; + + expect(saved.providerCustomerId).toBe("cust-1"); + }); +}); + +describe("RevokeSubscriptionUseCase", () => { + let repo: ISubscriptionRepository; + let billing: BillingPort; + let useCase: RevokeSubscriptionUseCase; + + beforeEach(() => { + repo = repository(); + vi.mocked(repo.findByUserId).mockResolvedValue(stored()); + billing = { + fetchSubscription: vi.fn(), + cancelSubscription: vi.fn().mockResolvedValue(true), + }; + useCase = new RevokeSubscriptionUseCase(repo, billing, logger()); + }); + + it("should cancel at the provider and take the badge away", async () => { + await expect(useCase.execute("user-1")).resolves.toBe(true); + + expect(billing.cancelSubscription).toHaveBeenCalledWith("gp-1"); + expect(repo.setVerifiedUntil).toHaveBeenCalledWith("user-1", null); + expect(vi.mocked(repo.save).mock.calls[0]![0].status).toBe( + SubscriptionStatus.REVOKED, + ); + }); + + it("should still revoke locally when the provider refuses", async () => { + // A provider outage must not leave a banned account wearing a badge; + // the nightly reconcile retries the cancellation. + vi.mocked(billing.cancelSubscription).mockRejectedValue( + new Error("provider down"), + ); + + await expect(useCase.execute("user-1")).resolves.toBe(true); + + expect(repo.setVerifiedUntil).toHaveBeenCalledWith("user-1", null); + }); + + it("should do nothing for an account that never subscribed", async () => { + vi.mocked(repo.findByUserId).mockResolvedValue(null); + + await expect(useCase.execute("user-1")).resolves.toBe(false); + expect(billing.cancelSubscription).not.toHaveBeenCalled(); + }); + + it("should do nothing when it has already been revoked", async () => { + vi.mocked(repo.findByUserId).mockResolvedValue( + stored({ status: SubscriptionStatus.REVOKED }), + ); + + await expect(useCase.execute("user-1")).resolves.toBe(false); + expect(billing.cancelSubscription).not.toHaveBeenCalled(); + }); +}); + +describe("ReconcileSubscriptionsUseCase", () => { + let repo: ISubscriptionRepository; + let billing: BillingPort; + let sync: Pick; + let revoke: Pick; + let useCase: ReconcileSubscriptionsUseCase; + + beforeEach(() => { + repo = repository(); + billing = { + fetchSubscription: vi.fn().mockResolvedValue(null), + cancelSubscription: vi.fn().mockResolvedValue(true), + }; + sync = { + execute: vi + .fn() + .mockResolvedValue({ applied: true, verifiedUntil: PERIOD_END }), + }; + revoke = { execute: vi.fn().mockResolvedValue(true) }; + + useCase = new ReconcileSubscriptionsUseCase( + repo, + billing, + sync as SyncSubscriptionUseCase, + revoke as RevokeSubscriptionUseCase, + logger(), + ); + }); + + it("should revoke a suspended account", async () => { + // A ban is applied by hand in SQL and has no code path to hook. This + // pass is the only thing that will ever notice. + vi.mocked(repo.findReconcilable).mockResolvedValue([ + { subscription: stored(), isBanned: true, isDeleted: false }, + ]); + + const result = await useCase.execute(100); + + expect(revoke.execute).toHaveBeenCalledWith("user-1"); + expect(result.revoked).toBe(1); + expect(billing.fetchSubscription).not.toHaveBeenCalled(); + }); + + it("should revoke a deleted account whose cancellation did not land", async () => { + vi.mocked(repo.findReconcilable).mockResolvedValue([ + { subscription: stored(), isBanned: false, isDeleted: true }, + ]); + + await useCase.execute(100); + + expect(revoke.execute).toHaveBeenCalledWith("user-1"); + }); + + it("should re-apply what the provider reports", async () => { + vi.mocked(repo.findReconcilable).mockResolvedValue([ + { subscription: stored(), isBanned: false, isDeleted: false }, + ]); + vi.mocked(billing.fetchSubscription).mockResolvedValue({ + providerSubscriptionId: "gp-1", + status: SubscriptionStatus.ACTIVE, + currentPeriodEnd: PERIOD_END, + }); + + const result = await useCase.execute(100); + + expect(result.repaired).toBe(1); + expect(sync.execute).toHaveBeenCalled(); + }); + + it("should leave a row alone when the provider cannot say", async () => { + // "I do not know this subscription" is not "it ended", and guessing + // between them is how a paying user loses a badge. + vi.mocked(repo.findReconcilable).mockResolvedValue([ + { subscription: stored(), isBanned: false, isDeleted: false }, + ]); + vi.mocked(billing.fetchSubscription).mockResolvedValue(null); + + const result = await useCase.execute(100); + + expect(sync.execute).not.toHaveBeenCalled(); + expect(result.repaired).toBe(0); + }); + + it("should carry on after one row fails", async () => { + vi.mocked(repo.findReconcilable).mockResolvedValue([ + { + subscription: stored({ userId: "user-1" }), + isBanned: false, + isDeleted: false, + }, + { + subscription: stored({ userId: "user-2" }), + isBanned: true, + isDeleted: false, + }, + ]); + vi.mocked(billing.fetchSubscription).mockRejectedValue( + new Error("provider down"), + ); + + const result = await useCase.execute(100); + + expect(result.examined).toBe(2); + expect(result.revoked).toBe(1); + }); +}); diff --git a/tests/unit/core/use-cases/profile/get-bot-profiles.usecase.test.ts b/tests/unit/core/use-cases/profile/get-bot-profiles.usecase.test.ts index 66877e34..285ec2b2 100644 --- a/tests/unit/core/use-cases/profile/get-bot-profiles.usecase.test.ts +++ b/tests/unit/core/use-cases/profile/get-bot-profiles.usecase.test.ts @@ -78,6 +78,7 @@ describe("GetBotProfilesUseCase", () => { username: "tsbot", fullName: "TypeScript Bot", avatarUrl: "https://example.com/avatar.png", + isVerified: false, bannerUrl: "https://example.com/banner.png", bio: "TS news", categories: [PostCategory.BACKEND], diff --git a/tests/unit/core/use-cases/profile/get-suggested-users.usecase.test.ts b/tests/unit/core/use-cases/profile/get-suggested-users.usecase.test.ts index e8727813..44428f29 100644 --- a/tests/unit/core/use-cases/profile/get-suggested-users.usecase.test.ts +++ b/tests/unit/core/use-cases/profile/get-suggested-users.usecase.test.ts @@ -71,6 +71,7 @@ describe("GetSuggestedUsersUseCase", () => { username: profile.username, fullName: profile.fullName, avatarUrl: profile.avatarUrl, + isVerified: false, bannerUrl: profile.bannerUrl, bio: profile.bio, followersCount: profile.followersCount, diff --git a/tests/unit/core/use-cases/user/soft-delete-user.usecase.test.ts b/tests/unit/core/use-cases/user/soft-delete-user.usecase.test.ts index a2bd3a30..7663daab 100644 --- a/tests/unit/core/use-cases/user/soft-delete-user.usecase.test.ts +++ b/tests/unit/core/use-cases/user/soft-delete-user.usecase.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { RevokeSubscriptionUseCase } from "@core/use-cases/billing/revoke-subscription"; import { SoftDeleteUserUseCase } from "@core/use-cases/user/soft-delete"; import type { IUserRepository } from "@core/ports/repositories/user.repository"; import type { PasswordPort } from "@core/ports/services/password.port"; @@ -11,6 +12,7 @@ describe("SoftDeleteUserUseCase", () => { let userRepository: Pick; let passwordService: Pick; let emailService: Pick; + let revokeSubscriptionUseCase: Pick; beforeEach(() => { userRepository = { @@ -23,10 +25,13 @@ describe("SoftDeleteUserUseCase", () => { emailService = { sendDeleteUserEmail: vi.fn().mockResolvedValue(undefined), }; + revokeSubscriptionUseCase = { execute: vi.fn().mockResolvedValue(true) }; + useCase = new SoftDeleteUserUseCase( userRepository as IUserRepository, passwordService as PasswordPort, emailService as EmailPort, + revokeSubscriptionUseCase as RevokeSubscriptionUseCase, ); }); @@ -121,4 +126,19 @@ describe("SoftDeleteUserUseCase", () => { to: "target@example.com", }); }); + + it("should stop the account being charged", async () => { + // The platform promises a deleted account is not billed again, and the + // provider is the only thing that can keep that promise. + vi.mocked(userRepository.findById).mockResolvedValue( + buildUser({ password: "hashed" }), + ); + vi.mocked(passwordService.verify).mockResolvedValue(true); + + await useCase.execute({ id: "user-1", password: "correct" }); + + expect(revokeSubscriptionUseCase.execute).toHaveBeenCalledWith( + "user-1", + ); + }); }); diff --git a/tests/unit/infrastructure/mappers/post-prisma.mapper.test.ts b/tests/unit/infrastructure/mappers/post-prisma.mapper.test.ts index 70a1f88f..054abaf8 100644 --- a/tests/unit/infrastructure/mappers/post-prisma.mapper.test.ts +++ b/tests/unit/infrastructure/mappers/post-prisma.mapper.test.ts @@ -349,6 +349,7 @@ describe("PostPrismaMapper", () => { profile: { avatarUrl: "uploads/quoted-avatar.jpg", fullName: "Quoted Author", + isVerified: false, }, }, }; @@ -412,6 +413,7 @@ describe("PostPrismaMapper", () => { username: "quoted-author", avatarUrl: `${CDN}/uploads/quoted-avatar.jpg`, fullName: "Quoted Author", + isVerified: false, }, }); });