From a2ca862dd97db2e9fb435a15aa75c98e8928efb5 Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 6 Sep 2026 11:12:41 +0300 Subject: [PATCH 1/2] feat(billing): add the paid verification badge, without a provider Everything the badge needs that is the same whichever store is billing. The store adapter - purchases, notifications, cancellations - lands separately. users.verifiedUntil is a date rather than a boolean so the badge expires on its own: a lost provider notification then costs it at the end of the period the user paid for, where a boolean would leave it on for good. It is denormalised because a dozen queries show an author and none of them should join a billing table to render a tick. Subscription.entitlementUntil is the single place a billing state becomes a badge, and SyncSubscriptionUseCase the single door state enters through - always the provider's absolute state, never a change, so a redelivered notification is harmless. It refuses a purchase already claimed by another account, and an event older than the one already applied: store notifications are unordered, and a late renewal would otherwise reinstate a subscription that has ended. Ban and deletion stop the billing. Deletion is a code path and revokes immediately; a ban is applied by hand in SQL and has none, so the nightly reconcile is the only thing that notices it. That pass also retries refused cancellations and repairs missed notifications, and leaves a row alone when the provider cannot say - that is not the same as saying it ended. NoopBillingService stands in until there is a store and never reports a subscription as active, so a misconfigured environment cannot hand out free badges. --- .env.example | 7 + CLAUDE.md | 13 + docs/verified-badge.md | 160 ++++++++++ .../migration.sql | 80 +++++ prisma/models/subscription.prisma | 101 +++++++ prisma/models/user.prisma | 16 + render.yaml | 7 + src/app.ts | 4 + src/core/domain/entities/comment.entity.ts | 1 + .../domain/entities/notification.entity.ts | 8 + src/core/domain/entities/post.entity.ts | 1 + src/core/domain/entities/profile.entity.ts | 8 + .../domain/entities/subscription.entity.ts | 128 ++++++++ .../domain/enums/billing-provider.enum.ts | 12 + src/core/domain/enums/index.ts | 2 + .../domain/enums/subscription-status.enum.ts | 29 ++ .../interfaces/article-props.interface.ts | 3 + .../interfaces/comment-props.interface.ts | 3 + .../conversation-props.interface.ts | 3 + .../notification-props.interface.ts | 3 + .../domain/interfaces/post-props.interface.ts | 8 + .../interfaces/profile-props.interface.ts | 3 + .../interfaces/quoted-post.interface.ts | 3 + .../subscription-props.interface.ts | 39 +++ .../repositories/subscription.repository.ts | 76 +++++ src/core/ports/services/billing.port.ts | 60 ++++ .../get-subscription.usecase.ts | 69 +++++ .../billing/get-subscription/index.ts | 6 + .../billing/reconcile-subscriptions/index.ts | 6 + .../reconcile-subscriptions.usecase.ts | 122 ++++++++ .../billing/revoke-subscription/index.ts | 5 + .../revoke-subscription.usecase.ts | 89 ++++++ .../billing/sync-subscription/index.ts | 9 + .../sync-subscription.usecase.ts | 138 +++++++++ .../get-bot-profiles.output.ts | 3 + .../get-bot-profiles.usecase.ts | 1 + .../get-suggested-users.output.ts | 3 + .../get-suggested-users.usecase.ts | 1 + .../shared/verification/is-verified.ts | 20 ++ .../soft-delete/soft-delete-user.usecase.ts | 13 + src/http/controllers/billing.controller.ts | 47 +++ .../custom/subscription-reconcile.plugin.ts | 42 +++ src/http/plugins/di/controllers.di.ts | 2 + src/http/plugins/di/external.di.ts | 8 + src/http/plugins/di/jobs.di.ts | 15 + src/http/plugins/di/persistence.di.ts | 5 + src/http/plugins/di/use-cases.di.ts | 26 ++ src/http/routes/billing.routes.ts | 37 +++ src/http/types/fastify-awilix.d.ts | 7 + .../schemas/article/article-item.schema.ts | 1 + .../schemas/billing/subscription.schema.ts | 36 +++ src/http/types/schemas/block/block.schema.ts | 1 + .../schemas/comment/get-comment.schema.ts | 1 + .../conversation/conversation.schema.ts | 1 + src/http/types/schemas/env.schema.ts | 11 + .../notification/get-notification.schema.ts | 1 + .../types/schemas/post/get-post.schema.ts | 1 + .../schemas/profile/bot-profiles.schema.ts | 1 + .../types/schemas/profile/followers.schema.ts | 1 + .../schemas/profile/get-profile.schema.ts | 1 + .../schemas/profile/search-profile.schema.ts | 1 + .../schemas/profile/suggested-users.schema.ts | 1 + .../external/billing/noop-billing.service.ts | 44 +++ .../billing/subscription-reconcile.job.ts | 26 ++ .../subscription-reconcile.scheduler.ts | 86 ++++++ .../mappers/article-prisma.mapper.ts | 5 + .../mappers/comment-prisma.mapper.ts | 5 + .../mappers/conversation-prisma.mapper.ts | 5 + .../mappers/notification-prisma.mapper.ts | 5 + .../persistence/mappers/post-prisma.mapper.ts | 9 + .../mappers/profile-prisma.mapper.ts | 5 + .../mappers/subscription-prisma.mapper.ts | 63 ++++ .../repositories/prisma-article.repository.ts | 2 + .../repositories/prisma-block.repository.ts | 3 + .../prisma-comment-bookmark.repository.ts | 1 + .../repositories/prisma-comment.repository.ts | 4 + .../repositories/prisma-follow.repository.ts | 5 + .../prisma-notification.repository.ts | 2 + .../repositories/prisma-post.repository.ts | 1 + .../prisma-subscription.repository.ts | 135 +++++++++ .../entities/subscription.entity.test.ts | 130 ++++++++ .../billing/subscription.usecase.test.ts | 284 ++++++++++++++++++ .../profile/get-bot-profiles.usecase.test.ts | 1 + .../get-suggested-users.usecase.test.ts | 1 + .../user/soft-delete-user.usecase.test.ts | 20 ++ 85 files changed, 2352 insertions(+) create mode 100644 docs/verified-badge.md create mode 100644 prisma/migrations/20260912000000_add_subscriptions/migration.sql create mode 100644 prisma/models/subscription.prisma create mode 100644 src/core/domain/entities/subscription.entity.ts create mode 100644 src/core/domain/enums/billing-provider.enum.ts create mode 100644 src/core/domain/enums/subscription-status.enum.ts create mode 100644 src/core/domain/interfaces/subscription-props.interface.ts create mode 100644 src/core/ports/repositories/subscription.repository.ts create mode 100644 src/core/ports/services/billing.port.ts create mode 100644 src/core/use-cases/billing/get-subscription/get-subscription.usecase.ts create mode 100644 src/core/use-cases/billing/get-subscription/index.ts create mode 100644 src/core/use-cases/billing/reconcile-subscriptions/index.ts create mode 100644 src/core/use-cases/billing/reconcile-subscriptions/reconcile-subscriptions.usecase.ts create mode 100644 src/core/use-cases/billing/revoke-subscription/index.ts create mode 100644 src/core/use-cases/billing/revoke-subscription/revoke-subscription.usecase.ts create mode 100644 src/core/use-cases/billing/sync-subscription/index.ts create mode 100644 src/core/use-cases/billing/sync-subscription/sync-subscription.usecase.ts create mode 100644 src/core/use-cases/shared/verification/is-verified.ts create mode 100644 src/http/controllers/billing.controller.ts create mode 100644 src/http/plugins/custom/subscription-reconcile.plugin.ts create mode 100644 src/http/routes/billing.routes.ts create mode 100644 src/http/types/schemas/billing/subscription.schema.ts create mode 100644 src/infrastructure/external/billing/noop-billing.service.ts create mode 100644 src/infrastructure/jobs/billing/subscription-reconcile.job.ts create mode 100644 src/infrastructure/jobs/billing/subscription-reconcile.scheduler.ts create mode 100644 src/infrastructure/persistence/mappers/subscription-prisma.mapper.ts create mode 100644 src/infrastructure/persistence/repositories/prisma-subscription.repository.ts create mode 100644 tests/unit/core/domain/entities/subscription.entity.test.ts create mode 100644 tests/unit/core/use-cases/billing/subscription.usecase.test.ts 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/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/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/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..0df64ed9 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 }; }; @@ -73,6 +76,7 @@ export interface PostResponse { avatarUrl: string; isMe?: boolean; fullName: string | null; + isVerified: boolean; }; isLiked: boolean; isBookmarked: boolean; @@ -112,6 +116,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 +151,9 @@ export class PostPrismaMapper { fullName: dbPost.quotedPost.author?.profile?.fullName ?? undefined, + isVerified: isVerified( + dbPost.quotedPost.author.verifiedUntil, + ), }, } : undefined, @@ -228,6 +236,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, 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..c2e3ef8b 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 { @@ -74,6 +75,7 @@ export class PrismaFollowUserRepository implements IFollowRepository { select: { id: true, username: true, + verifiedUntil: true, profile: { select: { fullName: true, @@ -91,6 +93,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, })); } @@ -118,6 +121,7 @@ export class PrismaFollowUserRepository implements IFollowRepository { select: { id: true, username: true, + verifiedUntil: true, profile: { select: { fullName: true, @@ -135,6 +139,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", + ); + }); }); From d872582ab2270c0c5201326e5cf7637e7b40342c Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 6 Sep 2026 11:25:35 +0300 Subject: [PATCH 2/2] fix(billing): supply the badge everywhere a schema now requires it The quote card, and the article list, build their author object field by field rather than passing one through. Both were missed when the badge was threaded into the read paths, and a required boolean the serialiser is not given is not an omission - it fails the response. Every endpoint carrying a quote card answered 500, which is what the e2e caught. The port summaries and use case outputs now declare the field too, so the next producer that forgets it fails to compile rather than at runtime. --- src/core/ports/repositories/block.repository.ts | 3 +++ src/core/ports/repositories/follow.repository.ts | 6 ++++++ .../use-cases/article/get-articles/get-articles.usecase.ts | 4 ++++ .../get-followers/get-followers-usecase.output.ts | 3 +++ .../get-following/get-following-usecase.output.ts | 3 +++ .../persistence/mappers/post-prisma.mapper.ts | 2 ++ .../persistence/repositories/prisma-follow.repository.ts | 2 ++ .../unit/infrastructure/mappers/post-prisma.mapper.test.ts | 2 ++ 8 files changed, 25 insertions(+) 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/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/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/infrastructure/persistence/mappers/post-prisma.mapper.ts b/src/infrastructure/persistence/mappers/post-prisma.mapper.ts index 0df64ed9..664c9a59 100644 --- a/src/infrastructure/persistence/mappers/post-prisma.mapper.ts +++ b/src/infrastructure/persistence/mappers/post-prisma.mapper.ts @@ -58,6 +58,7 @@ export interface QuotedPostResponse { username: string; avatarUrl: string; fullName: string | null; + isVerified: boolean; }; } @@ -286,6 +287,7 @@ export class PostPrismaMapper { cdnUrl, ), fullName: quoted.author.fullName ?? null, + isVerified: quoted.author.isVerified ?? false, }, }; } diff --git a/src/infrastructure/persistence/repositories/prisma-follow.repository.ts b/src/infrastructure/persistence/repositories/prisma-follow.repository.ts index c2e3ef8b..49fba2b7 100644 --- a/src/infrastructure/persistence/repositories/prisma-follow.repository.ts +++ b/src/infrastructure/persistence/repositories/prisma-follow.repository.ts @@ -62,6 +62,7 @@ export class PrismaFollowUserRepository implements IFollowRepository { username: string; fullName: string; avatarUrl: string; + isVerified: boolean; bio: string | null; }[] > { @@ -108,6 +109,7 @@ export class PrismaFollowUserRepository implements IFollowRepository { username: string; fullName: string; avatarUrl: string; + isVerified: boolean; bio: string | null; }[] > { 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, }, }); });