Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,10 @@ DEVICE_PURGE_CRON=0 6 * * *
SUBSCRIPTION_RECONCILE_CRON=0 3 * * *
SUBSCRIPTION_RECONCILE_BATCH_SIZE=500

# Shared secret Pub/Sub appends to the Play notification push URL. Empty keeps
# that endpoint closed - it writes billing state and carries no session.
PLAY_NOTIFICATIONS_TOKEN=

# --- Mobile clients ---
# How long after a rotation a retired refresh token is still accepted as a
# retry rather than treated as a stolen one. Mobile clients lose the *response*
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,10 @@ A monthly paid subscription, and the only way to get a tick — no official badg

`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.

**Google Play** enters through two endpoints. `POST /billing/play/purchases` is authenticated and is the *only* place the link between a purchase and an account is learned — Google's notifications name a token and a product and nothing else. It trusts the client for nothing: the token is verified with Google and what comes back is stored, so a purchase that cannot be confirmed links as `PENDING` with no badge. `POST /billing/play/notifications` takes the Pub/Sub push, guarded by a shared secret on the URL (`PLAY_NOTIFICATIONS_TOKEN`, empty = closed). A notification is a **nudge, never state**: it says a purchase changed, and the state is then read from Google, because deliveries are unordered and redelivered. It answers 204 for anything it understood — a redelivery, or a purchase no account claims — since Pub/Sub retries every non-2xx and retrying those achieves nothing. `mapPlayState` is the single place Google's vocabulary becomes ours, and an unrecognised state reads as *not* entitling.

`GooglePlayBillingService` does not exist yet: `NoopBillingService` stands in until the Play Console side is configured.

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

### Realtime and background jobs
Expand Down
58 changes: 58 additions & 0 deletions docs/verified-badge.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,12 +116,70 @@ 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.

## Google Play

Two endpoints, and a deliberate split between what the client is trusted for
and what it is not.

**`POST /api/v1/billing/play/purchases`** — authenticated. The app calls it
right after Play reports a successful purchase, with the `purchaseToken` the
billing library produced and the product id.

```json
{ "purchaseToken": "…", "productId": "verified_monthly" }
```

This call is the **only** place the link between a purchase and an account is
ever learned: Google's notifications name a token and a product and nothing
else. It grants nothing on the client's word — the token goes straight to
Google for verification and what comes back is what gets stored, so a
fabricated token buys a row that says `PENDING` and no badge. A purchase Google
cannot confirm right now is still linked, as `PENDING`, because without the row
nothing would ever connect that token to that account again; the nightly
reconcile finishes it.

**`POST /api/v1/billing/play/notifications`** — where Pub/Sub pushes Google's
notifications. No session, guarded by a shared secret on the URL
(`?token=…`, `PLAY_NOTIFICATIONS_TOKEN`). **Empty means the endpoint is
closed**, which is the right default for an unauthenticated route that writes
billing state.

The notification is treated as a **nudge, never as state**. It says a purchase
changed; what it changed to is then read from the Play Developer API, and that
answer is what gets stored. Notifications arrive out of order and are
redelivered, so believing their contents would mean reinstating subscriptions
that have ended.

It answers `204` for everything it understood — including a redelivery and a
purchase no account claims yet, since Pub/Sub retries anything that is not a
2xx and retrying either of those achieves nothing. A genuine failure escapes as
a 5xx, which is exactly the answer that makes Google try again.

Google's state maps onto ours in one place, `mapPlayState`. `ACTIVE` and
`IN_GRACE_PERIOD` entitle; `PAUSED`, `ON_HOLD`, `CANCELED` and `EXPIRED` do
not — none of them are being paid for — and a state we do not recognise reads
as *not* entitling, because Google adds values over time and the safe reading
of "I do not know this" is that the badge is off.

### Not done yet

`GooglePlayBillingService` — the `BillingPort` implementation that actually
calls the Play Developer API to verify a purchase and to cancel one. Until it
exists `NoopBillingService` stands in, so purchases link as `PENDING` and no
badge is granted. It needs the Play Console work: a subscription product, a
service account with "View financial data" and "Manage orders and
subscriptions", and the Pub/Sub topic.

Verifying the OIDC token Google can attach to a push is the stronger
alternative to the shared secret, and belongs with that same work.

## 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. |
| `PLAY_NOTIFICATIONS_TOKEN` | _(empty)_ | Shared secret on the push URL. Empty closes the endpoint. |

There is no provider yet. `NoopBillingService` stands in, and it deliberately
never reports a subscription as active — a stub that granted entitlements would
Expand Down
5 changes: 5 additions & 0 deletions render.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,11 @@ projects:
sync: false
- key: DEVICE_PURGE_CRON
sync: false
# Shared secret on the Play notification push URL. Empty keeps that
# endpoint closed, which is the right default for an unauthenticated
# route that writes billing state.
- key: PLAY_NOTIFICATIONS_TOKEN
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.
Expand Down
39 changes: 39 additions & 0 deletions src/core/ports/repositories/billing-event.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { BillingProvider } from "@core/domain/enums";

/**
* One provider notification, as recorded.
*/
export interface BillingEventRecord {
/** The provider's own identifier for the delivery. */
id: string;

provider: BillingProvider;

/** The provider's event type, as sent. */
type: string;

/** Which subscription it concerned, when it named one. */
providerSubscriptionId?: string | null;
}

/**
* Persistence contract for the record of provider notifications.
*/
export interface IBillingEventRepository {
/**
* Records a notification, unless it has already been recorded.
*
* The insert is the check: providers redeliver, and reading first would
* leave a window two deliveries of the same event both pass through.
*
* Not what makes replays safe - every sync writes the provider's absolute
* state, so applying one twice lands in the same place. This spares the
* duplicate work and, more usefully, leaves a trail of what arrived and
* when, which is the first thing anybody wants when a subscription is in
* the wrong state.
*
* @param event - The notification to record.
* @returns True when this caller recorded it, false when it was a repeat.
*/
recordIfNew(event: BillingEventRecord): Promise<boolean>;
}
6 changes: 6 additions & 0 deletions src/core/use-cases/billing/register-play-purchase/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* This module exports the RegisterPlayPurchaseUseCase, which attaches a Play
* purchase to the account that made it.
*/
export { RegisterPlayPurchaseUseCase } from "./register-play-purchase.usecase";
export type { RegisterPlayPurchaseInput } from "./register-play-purchase.usecase";
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { BillingProvider, SubscriptionStatus } from "@core/domain/enums";
import type { BillingPort } from "@core/ports/services/billing.port";
import type { LoggerPort } from "@core/ports/services/logger.port";
import type { SyncSubscriptionUseCase } from "../sync-subscription";

/**
* Input DTO for the RegisterPlayPurchaseUseCase.
*/
export interface RegisterPlayPurchaseInput {
/** The account that made the purchase, from its own session. */
currentUserId: string;

/** Google's identifier for the purchase, from the billing library. */
purchaseToken: string;

/** The subscription product that was bought. */
productId: string;
}

/**
* Use case for attaching a Play purchase to the account that made it.
*
* This call is the only place the link between a purchase and an account is
* ever learned. Google's notifications name a purchase token and a product and
* nothing else; the account behind it is known here, and only here, because
* this request carries a session.
*
* It grants nothing on the client's word. The token is handed straight to the
* provider for verification, and what comes back is what gets stored - so a
* fabricated token buys a row that says `PENDING` and no badge.
*/
export class RegisterPlayPurchaseUseCase {
/**
* Creates a new instance of RegisterPlayPurchaseUseCase.
*
* @param billingService - Asks Google what the purchase actually is
* @param syncSubscriptionUseCase - The one door billing state enters by
* @param logger - Records a purchase the provider could not confirm
*/
constructor(
private readonly billingService: BillingPort,
private readonly syncSubscriptionUseCase: SyncSubscriptionUseCase,
private readonly logger: LoggerPort,
) {}

/**
* Records the purchase and applies whatever the provider says about it.
*
* @param input - Who bought what
* @returns Whether the badge is granted as a result
*
* @remarks
* When the provider cannot confirm the purchase - no adapter configured
* yet, or Google momentarily unreachable - the link is still written, as
* `PENDING`. That grants nothing, and it is what lets the nightly
* reconcile finish the job later: without the row, nothing would ever
* connect this token to this account again.
*/
async execute(input: RegisterPlayPurchaseInput): Promise<{
isVerified: boolean;
}> {
const state = await this.billingService.fetchSubscription(
input.purchaseToken,
);

if (!state) {
this.logger.warn(
{
userId: input.currentUserId,
productId: input.productId,
},
"Play purchase could not be confirmed; linking it as pending",
);
}

const result = await this.syncSubscriptionUseCase.execute({
userId: input.currentUserId,
provider: BillingProvider.GOOGLE_PLAY,
state: state ?? {
providerSubscriptionId: input.purchaseToken,
status: SubscriptionStatus.PENDING,
eventAt: new Date(),
},
});

return { isVerified: result.verifiedUntil !== null };
}
}
89 changes: 89 additions & 0 deletions src/http/controllers/play-billing.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { UnauthorizedError } from "@core/errors";
import type { RegisterPlayPurchaseUseCase } from "@core/use-cases/billing/register-play-purchase";
import type { PlayNotificationService } from "@infrastructure/external/billing/play/play-notification.service";
import type {
PlayNotificationQuery,
RegisterPlayPurchaseBody,
} from "@typings/schemas/billing/play.schema";

/**
* Controller for the Google Play billing endpoints.
*/
export class PlayBillingController {
/**
* Creates a new PlayBillingController instance.
*
* @param registerPlayPurchaseUseCase - Attaches a purchase to an account
* @param playNotificationService - Handles what Google pushes
* @param config - Environment configuration, for the push secret
*/
constructor(
private readonly registerPlayPurchaseUseCase: RegisterPlayPurchaseUseCase,
private readonly playNotificationService: PlayNotificationService,
private readonly config: FastifyInstance["config"],
) {}

/**
* Takes a completed purchase from the app and verifies it.
*
* @param request - The authenticated request carrying the purchase token
* @param reply - The reply to send
*/
async registerPurchase(
request: FastifyRequest<{ Body: RegisterPlayPurchaseBody }>,
reply: FastifyReply,
): Promise<void> {
const result = await this.registerPlayPurchaseUseCase.execute({
currentUserId: request.user!.id,
purchaseToken: request.body.purchaseToken,
productId: request.body.productId,
});

reply.status(200).send({
data: result,
meta: { timestamp: new Date().toISOString() },
});
}

/**
* Receives a subscription notification pushed by Google.
*
* Answers 204 for everything it understood, including a duplicate and a
* purchase no account claims. Pub/Sub retries anything that is not a 2xx,
* and retrying either of those forever would achieve nothing - the first
* is already applied and the second needs the app's own call, not another
* delivery.
*
* The genuine retry case is an exception escaping this handler: that
* reaches the error handler as a 5xx, which is exactly the answer that
* makes Google try again.
*
* @param request - The push request from Pub/Sub
* @param reply - The reply to send
*
* @throws UnauthorizedError - When the push secret does not match
*/
async notifications(
request: FastifyRequest<{ Querystring: PlayNotificationQuery }>,
reply: FastifyReply,
): Promise<void> {
const expected = this.config.PLAY_NOTIFICATIONS_TOKEN;

// No secret configured means the endpoint is not wired up yet. Closed
// rather than open: an unauthenticated endpoint that writes billing
// state is not something to leave ajar by default.
if (!expected || request.query.token !== expected) {
throw new UnauthorizedError();
}

const outcome = await this.playNotificationService.handle(request.body);

request.log.info(
{ outcome, context: "PlayNotification" },
"Handled a Play billing notification",
);

reply.status(204).send();
}
}
2 changes: 2 additions & 0 deletions src/http/plugins/di/controllers.di.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ 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 { PlayBillingController } from "@controllers/play-billing.controller";
import { CommentController } from "@controllers/comment.controller";
import { BookmarkController } from "@controllers/bookmark.controller";
import { TrendController } from "@controllers/trend.controller";
Expand Down Expand Up @@ -62,6 +63,7 @@ export const controllersModule = {
metaController: asClass(MetaController).singleton(),
deviceController: asClass(DeviceController).singleton(),
billingController: asClass(BillingController).singleton(),
playBillingController: asClass(PlayBillingController).singleton(),
notificationController: asClass(NotificationController).singleton(),
postController: asClass(PostController).singleton(),
commentController: asClass(CommentController).singleton(),
Expand Down
8 changes: 8 additions & 0 deletions src/http/plugins/di/external.di.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
NoopPushService,
} from "@infrastructure/external/push/expo-push.service";
import { NoopBillingService } from "@infrastructure/external/billing/noop-billing.service";
import { PlayNotificationService } from "@infrastructure/external/billing/play/play-notification.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";
Expand Down Expand Up @@ -37,6 +38,13 @@ export const externalModule = {
*/
billingService: asClass(NoopBillingService).singleton(),

/**
* Handles what Google pushes about subscriptions. Useful before the
* provider adapter exists: it still records deliveries, spots
* redeliveries and finds the account a purchase belongs to.
*/
playNotificationService: asClass(PlayNotificationService).singleton(),

emailService: asFunction((config, logger) => {
return new EmailService(
{
Expand Down
2 changes: 2 additions & 0 deletions src/http/plugins/di/persistence.di.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { PrismaDigestDeliveryRepository } from "@infrastructure/persistence/repo
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 { PrismaBillingEventRepository } from "@infrastructure/persistence/repositories/prisma-billing-event.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";
Expand Down Expand Up @@ -150,6 +151,7 @@ export const persistenceModule = {
* Subscription repository for the paid verification badge
*/
subscriptionRepository: asClass(PrismaSubscriptionRepository).singleton(),
billingEventRepository: asClass(PrismaBillingEventRepository).singleton(),
reportDigestDeliveryRepository: asClass(
PrismaReportDigestDeliveryRepository,
).singleton(),
Expand Down
Loading