From 069f1bf74c4ead90f3d0ff6ad9fbceea1b123c4a Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 6 Sep 2026 12:24:47 +0300 Subject: [PATCH] feat(billing): take Google Play purchases and notifications The half of the Play adapter that does not need Google: the two endpoints, the notification envelope, the state mapping and the replay guard. What is missing is GooglePlayBillingService itself, which needs a Play Console that exists - until then NoopBillingService stands in and nothing is granted. POST /billing/play/purchases is authenticated because it 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 trusts the client for nothing - the token is handed to Google and what comes back is stored - so a purchase nobody can confirm links as PENDING with no badge, which is what lets the nightly reconcile finish it later. POST /billing/play/notifications takes the Pub/Sub push behind a shared secret on the URL; empty keeps it closed, which is the right default for an unauthenticated route that writes billing state. The notification is a nudge and never state - deliveries are unordered and redelivered, so what changed is read back from Google. It answers 204 for anything it understood, including a redelivery and a purchase no account claims, because Pub/Sub retries every non-2xx and retrying those achieves nothing. An unrecognised subscription state reads as not entitling. Google adds values over time and the safe reading of "I do not know this" is that the badge is off, not on. --- .env.example | 4 + CLAUDE.md | 4 + docs/verified-badge.md | 58 +++++ render.yaml | 5 + .../repositories/billing-event.repository.ts | 39 +++ .../billing/register-play-purchase/index.ts | 6 + .../register-play-purchase.usecase.ts | 88 +++++++ .../controllers/play-billing.controller.ts | 89 +++++++ src/http/plugins/di/controllers.di.ts | 2 + src/http/plugins/di/external.di.ts | 8 + src/http/plugins/di/persistence.di.ts | 2 + src/http/plugins/di/use-cases.di.ts | 8 + src/http/routes/billing.routes.ts | 48 ++++ src/http/types/fastify-awilix.d.ts | 4 + src/http/types/schemas/billing/play.schema.ts | 51 ++++ src/http/types/schemas/env.schema.ts | 5 + .../billing/play/play-notification.service.ts | 117 +++++++++ .../billing/play/play-notification.ts | 210 +++++++++++++++ .../prisma-billing-event.repository.ts | 49 ++++ tests/e2e/billing/play.test.ts | 129 +++++++++ .../register-play-purchase.usecase.test.ts | 100 +++++++ .../billing/play-notification.test.ts | 246 ++++++++++++++++++ 22 files changed, 1272 insertions(+) create mode 100644 src/core/ports/repositories/billing-event.repository.ts create mode 100644 src/core/use-cases/billing/register-play-purchase/index.ts create mode 100644 src/core/use-cases/billing/register-play-purchase/register-play-purchase.usecase.ts create mode 100644 src/http/controllers/play-billing.controller.ts create mode 100644 src/http/types/schemas/billing/play.schema.ts create mode 100644 src/infrastructure/external/billing/play/play-notification.service.ts create mode 100644 src/infrastructure/external/billing/play/play-notification.ts create mode 100644 src/infrastructure/persistence/repositories/prisma-billing-event.repository.ts create mode 100644 tests/e2e/billing/play.test.ts create mode 100644 tests/unit/core/use-cases/billing/register-play-purchase.usecase.test.ts create mode 100644 tests/unit/infrastructure/billing/play-notification.test.ts diff --git a/.env.example b/.env.example index 88dbb59..a81de9a 100644 --- a/.env.example +++ b/.env.example @@ -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* diff --git a/CLAUDE.md b/CLAUDE.md index aa37c5d..f6031e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/docs/verified-badge.md b/docs/verified-badge.md index 4f0e919..47d4bec 100644 --- a/docs/verified-badge.md +++ b/docs/verified-badge.md @@ -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 diff --git a/render.yaml b/render.yaml index 073dc2e..34f13b5 100644 --- a/render.yaml +++ b/render.yaml @@ -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. diff --git a/src/core/ports/repositories/billing-event.repository.ts b/src/core/ports/repositories/billing-event.repository.ts new file mode 100644 index 0000000..e97820b --- /dev/null +++ b/src/core/ports/repositories/billing-event.repository.ts @@ -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; +} diff --git a/src/core/use-cases/billing/register-play-purchase/index.ts b/src/core/use-cases/billing/register-play-purchase/index.ts new file mode 100644 index 0000000..25af64f --- /dev/null +++ b/src/core/use-cases/billing/register-play-purchase/index.ts @@ -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"; diff --git a/src/core/use-cases/billing/register-play-purchase/register-play-purchase.usecase.ts b/src/core/use-cases/billing/register-play-purchase/register-play-purchase.usecase.ts new file mode 100644 index 0000000..89f070e --- /dev/null +++ b/src/core/use-cases/billing/register-play-purchase/register-play-purchase.usecase.ts @@ -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 }; + } +} diff --git a/src/http/controllers/play-billing.controller.ts b/src/http/controllers/play-billing.controller.ts new file mode 100644 index 0000000..bf04e35 --- /dev/null +++ b/src/http/controllers/play-billing.controller.ts @@ -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 { + 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 { + 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(); + } +} diff --git a/src/http/plugins/di/controllers.di.ts b/src/http/plugins/di/controllers.di.ts index 0ee8d10..7cf7c1e 100644 --- a/src/http/plugins/di/controllers.di.ts +++ b/src/http/plugins/di/controllers.di.ts @@ -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"; @@ -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(), diff --git a/src/http/plugins/di/external.di.ts b/src/http/plugins/di/external.di.ts index 9e28dbe..91d5c07 100644 --- a/src/http/plugins/di/external.di.ts +++ b/src/http/plugins/di/external.di.ts @@ -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"; @@ -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( { diff --git a/src/http/plugins/di/persistence.di.ts b/src/http/plugins/di/persistence.di.ts index 0143483..9a978f9 100644 --- a/src/http/plugins/di/persistence.di.ts +++ b/src/http/plugins/di/persistence.di.ts @@ -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"; @@ -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(), diff --git a/src/http/plugins/di/use-cases.di.ts b/src/http/plugins/di/use-cases.di.ts index 5b60413..cefcda2 100644 --- a/src/http/plugins/di/use-cases.di.ts +++ b/src/http/plugins/di/use-cases.di.ts @@ -111,6 +111,7 @@ import { SyncSubscriptionUseCase } from "@core/use-cases/billing/sync-subscripti 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 { RegisterPlayPurchaseUseCase } from "@core/use-cases/billing/register-play-purchase"; import { REPORT_EXCERPT_LENGTH, REPORT_MAX_DETAILS, @@ -252,6 +253,13 @@ export const useCasesModule = { */ getSubscriptionUseCase: asClass(GetSubscriptionUseCase).singleton(), + /** + * Use case that attaches a Play purchase to the account that made it + */ + registerPlayPurchaseUseCase: asClass( + RegisterPlayPurchaseUseCase, + ).singleton(), + /** * Use case that repairs billing state nightly */ diff --git a/src/http/routes/billing.routes.ts b/src/http/routes/billing.routes.ts index 28b5edd..1278ba5 100644 --- a/src/http/routes/billing.routes.ts +++ b/src/http/routes/billing.routes.ts @@ -11,6 +11,13 @@ import { RateLimitPolicies } from "@plugins/rate-limit.plugin"; import { SubscriptionResponseSchema } from "@typings/schemas/billing/subscription.schema"; +import { + PlayNotificationQuerySchema, + RegisterPlayPurchaseBodySchema, + RegisterPlayPurchaseResponseSchema, + type PlayNotificationQuery, + type RegisterPlayPurchaseBody, +} from "@typings/schemas/billing/play.schema"; import type { FastifyInstance } from "fastify"; /** @@ -21,6 +28,8 @@ import type { FastifyInstance } from "fastify"; */ export default function billingRoutes(fastify: FastifyInstance): void { const billingController = fastify.diContainer.cradle.billingController; + const playBillingController = + fastify.diContainer.cradle.playBillingController; fastify.get( "/billing/subscription", @@ -34,4 +43,43 @@ export default function billingRoutes(fastify: FastifyInstance): void { }, billingController.subscription.bind(billingController), ); + + fastify.post<{ Body: RegisterPlayPurchaseBody }>( + "/billing/play/purchases", + { + schema: { + body: RegisterPlayPurchaseBodySchema, + response: { 200: RegisterPlayPurchaseResponseSchema }, + tags: ["Billing"], + }, + onRequest: [fastify.authenticate], + // A retried hand-over is already harmless: the sync writes the + // provider's absolute state onto one row per account, so reporting + // the same purchase twice lands in the same place. It should still + // carry `idempotency: true` once that plugin is on main, to save + // the second verification round trip. + config: { rateLimit: RateLimitPolicies.SENSITIVE }, + }, + playBillingController.registerPurchase.bind(playBillingController), + ); + + /** + * Where Google pushes subscription notifications. + * + * Unauthenticated in the session sense - Pub/Sub carries no account - and + * guarded by a shared secret on the URL instead. It is deliberately exempt + * from the standard rate limit: Google decides how often it calls, and + * answering 429 only makes it call again. + */ + fastify.post<{ Querystring: PlayNotificationQuery }>( + "/billing/play/notifications", + { + schema: { + querystring: PlayNotificationQuerySchema, + tags: ["Billing"], + }, + config: { rateLimit: false }, + }, + playBillingController.notifications.bind(playBillingController), + ); } diff --git a/src/http/types/fastify-awilix.d.ts b/src/http/types/fastify-awilix.d.ts index c3900ee..bd02091 100644 --- a/src/http/types/fastify-awilix.d.ts +++ b/src/http/types/fastify-awilix.d.ts @@ -12,6 +12,7 @@ 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 { PlayBillingController } from "@controllers/play-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"; @@ -100,6 +101,9 @@ declare module "@fastify/awilix" { /** Controller for the subscription endpoint */ billingController: BillingController; + /** Controller for the Google Play billing endpoints */ + playBillingController: PlayBillingController; + /** Scheduler that repairs billing state nightly */ subscriptionReconcileScheduler: SubscriptionReconcileScheduler; diff --git a/src/http/types/schemas/billing/play.schema.ts b/src/http/types/schemas/billing/play.schema.ts new file mode 100644 index 0000000..b3eae3d --- /dev/null +++ b/src/http/types/schemas/billing/play.schema.ts @@ -0,0 +1,51 @@ +import { type Static, Type } from "@fastify/type-provider-typebox"; +import { ResponseSchema } from "../create-response-schema"; + +/** + * A purchase the app has just completed, handed over for verification. + * + * The token is Google's, produced by the billing library on the device. It is + * not a claim to anything on its own - the server asks Google what it is - but + * this request is the only place the account behind it is known, because it is + * the only one carrying a session. + */ +export const RegisterPlayPurchaseBodySchema = Type.Object({ + purchaseToken: Type.String({ minLength: 1, maxLength: 4096 }), + productId: Type.String({ minLength: 1, maxLength: 200 }), +}); + +export type RegisterPlayPurchaseBody = Static< + typeof RegisterPlayPurchaseBodySchema +>; + +export const RegisterPlayPurchaseResponseSchema = ResponseSchema( + Type.Object({ + /** + * Whether the badge is granted as a result. + * + * False is not a failure: a purchase Google has not confirmed yet is + * linked to the account and left pending, and the nightly reconcile + * finishes it. + */ + isVerified: Type.Boolean(), + }), +); + +export type RegisterPlayPurchaseResponse = Static< + typeof RegisterPlayPurchaseResponseSchema +>; + +/** + * The shared secret Pub/Sub appends to the push URL. + * + * Checked as a query parameter because that is what a Pub/Sub push + * subscription can carry without any Google library on this side. It is a + * bearer secret and nothing more; verifying the OIDC token Google can also + * send is the stronger option and arrives with the rest of the Google + * integration. + */ +export const PlayNotificationQuerySchema = Type.Object({ + token: Type.Optional(Type.String({ maxLength: 200 })), +}); + +export type PlayNotificationQuery = Static; diff --git a/src/http/types/schemas/env.schema.ts b/src/http/types/schemas/env.schema.ts index b0ca9a1..d119b8c 100644 --- a/src/http/types/schemas/env.schema.ts +++ b/src/http/types/schemas/env.schema.ts @@ -272,6 +272,11 @@ export const EnvSchema = Type.Object({ minimum: 1, }), + // Shared secret Pub/Sub appends to the Play notification push URL. Empty + // - the default - closes that endpoint entirely rather than leaving an + // unauthenticated route that writes billing state open by default. + PLAY_NOTIFICATIONS_TOKEN: Type.String({ default: "" }), + // --- 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/infrastructure/external/billing/play/play-notification.service.ts b/src/infrastructure/external/billing/play/play-notification.service.ts new file mode 100644 index 0000000..a8f6f8b --- /dev/null +++ b/src/infrastructure/external/billing/play/play-notification.service.ts @@ -0,0 +1,117 @@ +import { BillingProvider } from "@core/domain/enums"; +import type { IBillingEventRepository } from "@core/ports/repositories/billing-event.repository"; +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 { SyncSubscriptionUseCase } from "@core/use-cases/billing/sync-subscription"; +import { parsePlayNotification } from "./play-notification"; + +/** + * What handling a notification came to. + * + * Reported rather than logged and forgotten because the caller has to turn it + * into a status code, and the difference between "we could not use this" and + * "try again later" is the difference between Google giving up and Google + * retrying. + */ +export type PlayNotificationOutcome = + "applied" | "duplicate" | "unknown-purchase" | "ignored" | "test"; + +/** + * Handles the notifications Google pushes about subscriptions. + * + * 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. + */ +export class PlayNotificationService { + /** + * Creates a new instance of PlayNotificationService. + * + * @param billingService - Asks Google what is true now + * @param subscriptionRepository - Finds the account a purchase belongs to + * @param billingEventRepository - Spots a redelivery, and keeps the trail + * @param syncSubscriptionUseCase - The one door billing state enters by + * @param logger - Records what could not be handled + */ + constructor( + private readonly billingService: BillingPort, + private readonly subscriptionRepository: ISubscriptionRepository, + private readonly billingEventRepository: IBillingEventRepository, + private readonly syncSubscriptionUseCase: SyncSubscriptionUseCase, + private readonly logger: LoggerPort, + ) {} + + /** + * Handles one pushed notification. + * + * @param body - The raw Pub/Sub push body + * @returns What became of it + */ + async handle(body: unknown): Promise { + const parsed = parsePlayNotification(body); + + if (parsed.kind === "test") return "test"; + + if (parsed.kind === "ignored") { + this.logger.warn( + { messageId: parsed.messageId, reason: parsed.reason }, + "Ignored a Play notification", + ); + + return "ignored"; + } + + const { notification } = parsed; + + const isNew = await this.billingEventRepository.recordIfNew({ + id: notification.messageId, + provider: BillingProvider.GOOGLE_PLAY, + type: notification.typeName, + providerSubscriptionId: notification.purchaseToken, + }); + + if (!isNew) return "duplicate"; + + const subscription = + await this.subscriptionRepository.findByProviderSubscriptionId( + notification.purchaseToken, + ); + + // A purchase nobody has claimed. It happens legitimately: Google can + // push the purchase notification before the app's own call arrives. + // There is no account to apply it to, and inventing one is not an + // option - the app's authenticated call is the only thing that knows + // whose purchase this is, and it will sync the state itself. + if (!subscription) { + this.logger.warn( + { + messageId: notification.messageId, + type: notification.typeName, + }, + "Play notification for a purchase no account claims", + ); + + return "unknown-purchase"; + } + + const state = await this.billingService.fetchSubscription( + notification.purchaseToken, + ); + + // The provider cannot say. Left alone rather than guessed at: the + // expiry already on the row retires the badge if the subscription is + // really over, and the nightly reconcile will ask again. + if (!state) return "unknown-purchase"; + + await this.syncSubscriptionUseCase.execute({ + userId: subscription.userId, + provider: BillingProvider.GOOGLE_PLAY, + state: { ...state, eventAt: state.eventAt ?? notification.eventAt }, + }); + + return "applied"; + } +} diff --git a/src/infrastructure/external/billing/play/play-notification.ts b/src/infrastructure/external/billing/play/play-notification.ts new file mode 100644 index 0000000..51058c1 --- /dev/null +++ b/src/infrastructure/external/billing/play/play-notification.ts @@ -0,0 +1,210 @@ +import { SubscriptionStatus } from "@core/domain/enums"; + +/** + * What Google says happened to a subscription. + * + * The numbers are Google's, and they are the whole reason this table exists: + * a notification arrives as `notificationType: 13` and nothing else. + * + * Note that none of them are trusted as *state*. Every one of them means the + * same thing to us - "ask Google what is true now" - because notifications + * arrive out of order and can be redelivered, while a fetch answers with the + * present. The names are kept for the audit trail and the log line. + */ +export const PLAY_NOTIFICATION_TYPES: Record = { + 1: "SUBSCRIPTION_RECOVERED", + 2: "SUBSCRIPTION_RENEWED", + 3: "SUBSCRIPTION_CANCELED", + 4: "SUBSCRIPTION_PURCHASED", + 5: "SUBSCRIPTION_ON_HOLD", + 6: "SUBSCRIPTION_IN_GRACE_PERIOD", + 7: "SUBSCRIPTION_RESTARTED", + 8: "SUBSCRIPTION_PRICE_CHANGE_CONFIRMED", + 9: "SUBSCRIPTION_DEFERRED", + 10: "SUBSCRIPTION_PAUSED", + 11: "SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED", + 12: "SUBSCRIPTION_REVOKED", + 13: "SUBSCRIPTION_EXPIRED", + 20: "SUBSCRIPTION_PENDING_PURCHASE_CANCELED", +}; + +/** + * Google's own name for where a subscription stands. + * + * Returned by the Play Developer API, not by the notification. + */ +export type PlaySubscriptionState = + | "SUBSCRIPTION_STATE_PENDING" + | "SUBSCRIPTION_STATE_ACTIVE" + | "SUBSCRIPTION_STATE_PAUSED" + | "SUBSCRIPTION_STATE_IN_GRACE_PERIOD" + | "SUBSCRIPTION_STATE_ON_HOLD" + | "SUBSCRIPTION_STATE_CANCELED" + | "SUBSCRIPTION_STATE_EXPIRED"; + +/** + * One subscription notification, once the envelope is off. + */ +export interface PlaySubscriptionNotification { + /** Google's identifier for this delivery, used to spot a redelivery. */ + messageId: string; + + /** The purchase this concerns. Our `providerSubscriptionId`. */ + purchaseToken: string; + + /** The subscription product. */ + subscriptionId: string; + + /** Google's numeric type. */ + notificationType: number; + + /** That type as a name, for the audit trail. */ + typeName: string; + + /** When Google says the event happened. */ + eventAt: Date; +} + +/** + * What arrived, once the envelope is off. + * + * A test notification is a real thing Google sends when somebody presses "Send + * test notification" in the console, and it names no subscription. Recognised + * rather than treated as malformed, because it is the first thing anybody does + * when wiring this up and answering it with a 400 makes it look broken. + */ +export type ParsedPlayNotification = + | { kind: "subscription"; notification: PlaySubscriptionNotification } + | { kind: "test"; messageId: string } + | { kind: "ignored"; messageId: string; reason: string }; + +/** + * Turns Google's subscription state into ours. + * + * `PAUSED` maps to `CANCELED` deliberately: a paused subscription is not being + * paid for, and the badge is a thing you have while paying. It comes back on + * its own when Google reports the subscription active again. + * + * `ON_HOLD` maps to `CANCELED` for the same reason - Google has already + * stopped the entitlement by then - while `IN_GRACE_PERIOD` maps to + * `IN_GRACE`, which keeps the badge: the user paid for the period they are in + * and Google is still retrying the next payment. + * + * @param state - Google's state, whatever it sent + * @returns The status to store + */ +export function mapPlayState(state: string): SubscriptionStatus { + switch (state) { + case "SUBSCRIPTION_STATE_ACTIVE": + return SubscriptionStatus.ACTIVE; + case "SUBSCRIPTION_STATE_IN_GRACE_PERIOD": + return SubscriptionStatus.IN_GRACE; + case "SUBSCRIPTION_STATE_PENDING": + return SubscriptionStatus.PENDING; + case "SUBSCRIPTION_STATE_PAUSED": + case "SUBSCRIPTION_STATE_ON_HOLD": + case "SUBSCRIPTION_STATE_CANCELED": + case "SUBSCRIPTION_STATE_EXPIRED": + return SubscriptionStatus.CANCELED; + default: + // An unknown state is not an active one. Google adds values over + // time, and the safe reading of "I do not recognise this" is that + // the badge is not granted rather than that it is. + return SubscriptionStatus.CANCELED; + } +} + +/** + * The shape Pub/Sub pushes: the notification, base64, inside an envelope. + */ +interface PubSubPushBody { + message?: { + data?: string; + messageId?: string; + message_id?: string; + publishTime?: string; + }; +} + +interface DeveloperNotification { + version?: string; + packageName?: string; + eventTimeMillis?: string; + subscriptionNotification?: { + notificationType?: number; + purchaseToken?: string; + subscriptionId?: string; + }; + testNotification?: { version?: string }; + voidedPurchaseNotification?: { purchaseToken?: string }; + oneTimeProductNotification?: unknown; +} + +/** + * Unwraps a Pub/Sub push into the notification inside it. + * + * Total rather than throwing: this runs on an endpoint Google calls, and every + * outcome other than "understood and acted on" has to be reported as something + * Google will not retry forever. A malformed body is `ignored`, not an error. + * + * @param body - The raw request body + * @returns What arrived, and enough to act on it + */ +export function parsePlayNotification(body: unknown): ParsedPlayNotification { + const envelope = body as PubSubPushBody | undefined; + const message = envelope?.message; + const messageId = message?.messageId ?? message?.message_id ?? ""; + + if (!message?.data) { + return { kind: "ignored", messageId, reason: "no message data" }; + } + + let decoded: DeveloperNotification; + + try { + decoded = JSON.parse( + Buffer.from(message.data, "base64").toString("utf8"), + ) as DeveloperNotification; + } catch { + return { kind: "ignored", messageId, reason: "undecodable payload" }; + } + + if (decoded.testNotification) { + return { kind: "test", messageId }; + } + + const subscription = decoded.subscriptionNotification; + + if (!subscription?.purchaseToken || !subscription.notificationType) { + return { + kind: "ignored", + messageId, + reason: decoded.voidedPurchaseNotification + ? "voided purchase, handled by the next fetch" + : "not a subscription notification", + }; + } + + const eventMillis = Number(decoded.eventTimeMillis); + + return { + kind: "subscription", + notification: { + // Falling back to the purchase token keeps the dedupe key + // meaningful even if Pub/Sub ever omits the id: a redelivery of + // the same event then still collides. + messageId: + messageId || + `${subscription.purchaseToken}:${decoded.eventTimeMillis ?? ""}`, + purchaseToken: subscription.purchaseToken, + subscriptionId: subscription.subscriptionId ?? "", + notificationType: subscription.notificationType, + typeName: + PLAY_NOTIFICATION_TYPES[subscription.notificationType] ?? + `UNKNOWN_${subscription.notificationType}`, + eventAt: Number.isFinite(eventMillis) + ? new Date(eventMillis) + : new Date(), + }, + }; +} diff --git a/src/infrastructure/persistence/repositories/prisma-billing-event.repository.ts b/src/infrastructure/persistence/repositories/prisma-billing-event.repository.ts new file mode 100644 index 0000000..e22c007 --- /dev/null +++ b/src/infrastructure/persistence/repositories/prisma-billing-event.repository.ts @@ -0,0 +1,49 @@ +import type { + BillingEventRecord, + IBillingEventRepository, +} from "@core/ports/repositories/billing-event.repository"; +import type { PrismaTransactionalClient } from "@infrastructure/persistence/database/prisma-client.type"; +import { Prisma } from "@generated/prisma/client"; +import type { BillingProvider as PrismaBillingProvider } from "@generated/prisma/client"; + +/** + * Prisma implementation of the billing event repository. + */ +export class PrismaBillingEventRepository implements IBillingEventRepository { + /** + * @param prisma - Prisma client, possibly scoped to a transaction + */ + constructor(private readonly prisma: PrismaTransactionalClient) {} + + /** + * Records a notification, unless it has already been recorded. + * + * @param event - The notification to record. + * @returns True when this caller recorded it. + */ + async recordIfNew(event: BillingEventRecord): Promise { + try { + await this.prisma.billingEvent.create({ + data: { + id: event.id, + provider: event.provider as PrismaBillingProvider, + type: event.type, + providerSubscriptionId: + event.providerSubscriptionId ?? null, + }, + }); + + return true; + } catch (error: unknown) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + // A redelivery. Already recorded, already applied. + return false; + } + + throw error; + } + } +} diff --git a/tests/e2e/billing/play.test.ts b/tests/e2e/billing/play.test.ts new file mode 100644 index 0000000..8dd330b --- /dev/null +++ b/tests/e2e/billing/play.test.ts @@ -0,0 +1,129 @@ +import { authRequest, parseBody, request } from "../setup"; +import { beforeAll, describe, expect, it } from "vitest"; + +/** + * E2E tests for the Google Play endpoints. + * + * There is no provider behind them yet - `NoopBillingService` confirms nothing - + * so what is testable is the half that does not need Google: who may call + * these, what a purchase nobody can confirm does, and that the notification + * endpoint is closed to anybody without the push secret. + */ +describe("Play billing", () => { + const ts = Date.now(); + const user = { + email: `play-${ts}@test.com`, + password: "password123", + username: `play${ts}`, + }; + + let token = ""; + + beforeAll(async () => { + await request({ method: "POST", url: "/auth/register", payload: user }); + + const loggedIn = await request({ + method: "POST", + url: "/auth/login", + payload: { identifier: user.email, password: user.password }, + }); + + token = parseBody<{ data: { accessToken: string } }>(loggedIn).data + .accessToken; + }); + + describe("POST /billing/play/purchases", () => { + it("should link an unconfirmable purchase without granting a badge", async () => { + const response = await authRequest(token, { + method: "POST", + url: "/billing/play/purchases", + payload: { + purchaseToken: `tok-${ts}`, + productId: "verified_monthly", + }, + }); + + expect(response.statusCode).toBe(200); + // Nothing confirmed it, so nothing is granted - but the account is + // now attached to the token, which is the part only this + // authenticated call can know. + expect( + parseBody<{ data: { isVerified: boolean } }>(response).data + .isVerified, + ).toBe(false); + }); + + it("should show up as a pending subscription", async () => { + const response = await authRequest(token, { + method: "GET", + url: "/billing/subscription", + }); + + const data = parseBody<{ + data: { status: string | null; isVerified: boolean }; + }>(response).data; + + expect(data.status).toBe("PENDING"); + expect(data.isVerified).toBe(false); + }); + + it("should require a session", async () => { + const response = await request({ + method: "POST", + url: "/billing/play/purchases", + payload: { purchaseToken: "tok-x", productId: "p" }, + }); + + expect(response.statusCode).toBe(401); + }); + + it("should reject an empty purchase token", async () => { + const response = await authRequest(token, { + method: "POST", + url: "/billing/play/purchases", + payload: { purchaseToken: "", productId: "verified_monthly" }, + }); + + expect(response.statusCode).toBe(400); + }); + }); + + describe("POST /billing/play/notifications", () => { + const body = { + message: { + messageId: `msg-${ts}`, + data: Buffer.from( + JSON.stringify({ + eventTimeMillis: "1767225600000", + subscriptionNotification: { + notificationType: 2, + purchaseToken: `tok-${ts}`, + }, + }), + ).toString("base64"), + }, + }; + + it("should refuse a caller with no push secret", async () => { + // The endpoint writes billing state and carries no session; with + // no secret configured it stays closed rather than open. + const response = await request({ + method: "POST", + url: "/billing/play/notifications", + payload: body, + }); + + expect(response.statusCode).toBe(401); + }); + + it("should refuse a wrong secret", async () => { + const response = await request({ + method: "POST", + url: "/billing/play/notifications?token=guessed", + payload: body, + }); + + expect(response.statusCode).toBe(401); + }); + }); +}); diff --git a/tests/unit/core/use-cases/billing/register-play-purchase.usecase.test.ts b/tests/unit/core/use-cases/billing/register-play-purchase.usecase.test.ts new file mode 100644 index 0000000..75de531 --- /dev/null +++ b/tests/unit/core/use-cases/billing/register-play-purchase.usecase.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { RegisterPlayPurchaseUseCase } from "@core/use-cases/billing/register-play-purchase"; +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 "@core/use-cases/billing/sync-subscription"; + +const PERIOD_END = new Date("2026-12-01T00:00:00Z"); + +describe("RegisterPlayPurchaseUseCase", () => { + let billing: BillingPort; + let sync: Pick; + let logger: LoggerPort; + let useCase: RegisterPlayPurchaseUseCase; + + const input = { + currentUserId: "user-1", + purchaseToken: "tok-1", + productId: "verified_monthly", + }; + + beforeEach(() => { + billing = { + fetchSubscription: vi.fn().mockResolvedValue({ + providerSubscriptionId: "tok-1", + status: SubscriptionStatus.ACTIVE, + currentPeriodEnd: PERIOD_END, + }), + cancelSubscription: vi.fn(), + }; + sync = { + execute: vi + .fn() + .mockResolvedValue({ applied: true, verifiedUntil: PERIOD_END }), + }; + logger = { error: vi.fn(), warn: vi.fn() } as unknown as LoggerPort; + + useCase = new RegisterPlayPurchaseUseCase( + billing, + sync as SyncSubscriptionUseCase, + logger, + ); + }); + + it("should verify the purchase with the provider before granting anything", async () => { + const result = await useCase.execute(input); + + expect(billing.fetchSubscription).toHaveBeenCalledWith("tok-1"); + expect(result.isVerified).toBe(true); + }); + + it("should store what the provider says, not what the client sent", async () => { + await useCase.execute(input); + + expect(sync.execute).toHaveBeenCalledWith({ + userId: "user-1", + provider: BillingProvider.GOOGLE_PLAY, + state: expect.objectContaining({ + status: SubscriptionStatus.ACTIVE, + currentPeriodEnd: PERIOD_END, + }), + }); + }); + + it("should link an unconfirmable purchase as pending rather than drop it", async () => { + // No adapter configured yet, or Google momentarily unreachable. The + // link still has to be written: this request is the only thing that + // knows whose purchase this is. + vi.mocked(billing.fetchSubscription).mockResolvedValue(null); + vi.mocked(sync.execute).mockResolvedValue({ + applied: true, + verifiedUntil: null, + }); + + const result = await useCase.execute(input); + + expect(result.isVerified).toBe(false); + expect(sync.execute).toHaveBeenCalledWith( + expect.objectContaining({ + state: expect.objectContaining({ + providerSubscriptionId: "tok-1", + status: SubscriptionStatus.PENDING, + }), + }), + ); + expect(logger.warn).toHaveBeenCalled(); + }); + + it("should report no badge when the sync grants none", async () => { + vi.mocked(sync.execute).mockResolvedValue({ + applied: false, + reason: "claimed-by-another-account", + verifiedUntil: null, + }); + + await expect(useCase.execute(input)).resolves.toEqual({ + isVerified: false, + }); + }); +}); diff --git a/tests/unit/infrastructure/billing/play-notification.test.ts b/tests/unit/infrastructure/billing/play-notification.test.ts new file mode 100644 index 0000000..3b43053 --- /dev/null +++ b/tests/unit/infrastructure/billing/play-notification.test.ts @@ -0,0 +1,246 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + mapPlayState, + parsePlayNotification, +} from "@infrastructure/external/billing/play/play-notification"; +import { PlayNotificationService } from "@infrastructure/external/billing/play/play-notification.service"; +import { Subscription } from "@core/domain/entities/subscription.entity"; +import { BillingProvider, SubscriptionStatus } from "@core/domain/enums"; +import type { IBillingEventRepository } from "@core/ports/repositories/billing-event.repository"; +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 { SyncSubscriptionUseCase } from "@core/use-cases/billing/sync-subscription"; + +/** + * Wraps a developer notification the way Pub/Sub pushes it. + */ +function push(payload: unknown, messageId = "msg-1"): unknown { + return { + message: { + messageId, + data: Buffer.from(JSON.stringify(payload)).toString("base64"), + }, + }; +} + +describe("parsePlayNotification", () => { + it("should unwrap a subscription notification", () => { + const parsed = parsePlayNotification( + push({ + eventTimeMillis: "1767225600000", + subscriptionNotification: { + notificationType: 2, + purchaseToken: "tok-1", + subscriptionId: "verified_monthly", + }, + }), + ); + + expect(parsed.kind).toBe("subscription"); + + if (parsed.kind !== "subscription") return; + + expect(parsed.notification.purchaseToken).toBe("tok-1"); + expect(parsed.notification.typeName).toBe("SUBSCRIPTION_RENEWED"); + expect(parsed.notification.eventAt).toEqual(new Date(1767225600000)); + }); + + it("should name a type Google has not documented here", () => { + const parsed = parsePlayNotification( + push({ + eventTimeMillis: "1767225600000", + subscriptionNotification: { + notificationType: 99, + purchaseToken: "tok-1", + }, + }), + ); + + if (parsed.kind !== "subscription") throw new Error("expected one"); + + expect(parsed.notification.typeName).toBe("UNKNOWN_99"); + }); + + it("should recognise the console's test notification", () => { + // The first thing anybody does when wiring this up. Answering it as + // malformed makes the integration look broken when it is not. + expect( + parsePlayNotification(push({ testNotification: { version: "1.0" } })) + .kind, + ).toBe("test"); + }); + + it("should ignore rather than throw on anything unusable", () => { + for (const body of [ + undefined, + {}, + { message: {} }, + { message: { data: "not base64 json" } }, + push({ oneTimeProductNotification: {} }), + push({ voidedPurchaseNotification: { purchaseToken: "t" } }), + ]) { + expect(parsePlayNotification(body).kind).toBe("ignored"); + } + }); + + it("should fall back to a key of its own when Pub/Sub sends no id", () => { + const parsed = parsePlayNotification({ + message: { + data: Buffer.from( + JSON.stringify({ + eventTimeMillis: "1767225600000", + subscriptionNotification: { + notificationType: 2, + purchaseToken: "tok-1", + }, + }), + ).toString("base64"), + }, + }); + + if (parsed.kind !== "subscription") throw new Error("expected one"); + + // A redelivery of the same event still collides on this. + expect(parsed.notification.messageId).toBe("tok-1:1767225600000"); + }); +}); + +describe("mapPlayState", () => { + it("should entitle only active and grace", () => { + expect(mapPlayState("SUBSCRIPTION_STATE_ACTIVE")).toBe( + SubscriptionStatus.ACTIVE, + ); + expect(mapPlayState("SUBSCRIPTION_STATE_IN_GRACE_PERIOD")).toBe( + SubscriptionStatus.IN_GRACE, + ); + }); + + it("should treat paused and on-hold as not paying", () => { + // Neither is being paid for, and the badge is a thing you have while + // paying. Both come back on their own when Google says active again. + expect(mapPlayState("SUBSCRIPTION_STATE_PAUSED")).toBe( + SubscriptionStatus.CANCELED, + ); + expect(mapPlayState("SUBSCRIPTION_STATE_ON_HOLD")).toBe( + SubscriptionStatus.CANCELED, + ); + }); + + it("should read a state it does not know as not entitling", () => { + // Google adds values over time; the safe reading of "I do not + // recognise this" is that the badge is off, not on. + expect(mapPlayState("SUBSCRIPTION_STATE_SOMETHING_NEW")).toBe( + SubscriptionStatus.CANCELED, + ); + }); +}); + +describe("PlayNotificationService", () => { + let billing: BillingPort; + let subscriptions: Pick< + ISubscriptionRepository, + "findByProviderSubscriptionId" + >; + let events: IBillingEventRepository; + let sync: Pick; + let service: PlayNotificationService; + + const stored = Subscription.with({ + id: "sub-1", + userId: "user-1", + provider: BillingProvider.GOOGLE_PLAY, + providerSubscriptionId: "tok-1", + status: SubscriptionStatus.ACTIVE, + }); + + const body = push({ + eventTimeMillis: "1767225600000", + subscriptionNotification: { + notificationType: 2, + purchaseToken: "tok-1", + }, + }); + + beforeEach(() => { + billing = { + fetchSubscription: vi.fn().mockResolvedValue({ + providerSubscriptionId: "tok-1", + status: SubscriptionStatus.ACTIVE, + currentPeriodEnd: new Date("2026-12-01T00:00:00Z"), + }), + cancelSubscription: vi.fn(), + }; + subscriptions = { + findByProviderSubscriptionId: vi.fn().mockResolvedValue(stored), + }; + events = { recordIfNew: vi.fn().mockResolvedValue(true) }; + sync = { execute: vi.fn().mockResolvedValue({ applied: true }) }; + + service = new PlayNotificationService( + billing, + subscriptions as ISubscriptionRepository, + events, + sync as SyncSubscriptionUseCase, + { error: vi.fn(), warn: vi.fn() } as unknown as LoggerPort, + ); + }); + + it("should ask Google what is true rather than believe the notification", async () => { + await expect(service.handle(body)).resolves.toBe("applied"); + + // The notification said "renewed"; what got stored is what the fetch + // returned. Notifications arrive out of order and are redelivered, so + // their contents are a nudge, never state. + expect(billing.fetchSubscription).toHaveBeenCalledWith("tok-1"); + expect(sync.execute).toHaveBeenCalledWith( + expect.objectContaining({ userId: "user-1" }), + ); + }); + + it("should do nothing twice for a redelivery", async () => { + vi.mocked(events.recordIfNew).mockResolvedValue(false); + + await expect(service.handle(body)).resolves.toBe("duplicate"); + expect(billing.fetchSubscription).not.toHaveBeenCalled(); + expect(sync.execute).not.toHaveBeenCalled(); + }); + + it("should not invent an account for a purchase nobody claims", async () => { + // Google can push the purchase notification before the app's own + // authenticated call arrives; that call is the only thing that knows + // whose purchase it is. + vi.mocked(subscriptions.findByProviderSubscriptionId).mockResolvedValue( + null, + ); + + await expect(service.handle(body)).resolves.toBe("unknown-purchase"); + expect(sync.execute).not.toHaveBeenCalled(); + }); + + it("should leave the row alone when Google cannot say", async () => { + vi.mocked(billing.fetchSubscription).mockResolvedValue(null); + + await expect(service.handle(body)).resolves.toBe("unknown-purchase"); + expect(sync.execute).not.toHaveBeenCalled(); + }); + + it("should record the delivery before acting on it", async () => { + await service.handle(body); + + expect(events.recordIfNew).toHaveBeenCalledWith({ + id: "msg-1", + provider: BillingProvider.GOOGLE_PLAY, + type: "SUBSCRIPTION_RENEWED", + providerSubscriptionId: "tok-1", + }); + }); + + it("should pass a test notification through untouched", async () => { + await expect( + service.handle(push({ testNotification: {} })), + ).resolves.toBe("test"); + + expect(events.recordIfNew).not.toHaveBeenCalled(); + }); +});