From 2b79e5776ea29d45a7fb8428e1b8338973a37313 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Thu, 17 Sep 2026 18:08:49 +0900 Subject: [PATCH] merging after changes --- database.types.ts | 18 +- docs/runbook.md | 4 +- llms.txt | 33 +- scripts/stripe-bootstrap.mjs | 97 ++- src/v1/auth/guards/staff.guard.spec.ts | 34 + src/v1/auth/guards/staff.guard.ts | 27 + src/v1/devices/devices.service.ts | 3 +- src/v1/locations/locations.module.ts | 3 +- src/v1/locations/locations.service.spec.ts | 14 +- src/v1/locations/locations.service.ts | 18 +- .../dto/admin-set-billing-mode.dto.ts | 13 + .../dto/admin-set-manual-seats.dto.ts | 16 + .../payments/dto/admin-set-reporting.dto.ts | 11 + ...base.dto.ts => cancel-subscription.dto.ts} | 2 +- src/v1/payments/dto/change-seats.dto.ts | 10 +- .../payments/dto/create-base-checkout.dto.ts | 14 - .../dto/create-device-checkout.dto.ts | 9 +- src/v1/payments/payments.controller.ts | 160 +++- src/v1/payments/payments.service.spec.ts | 557 +++++++++++-- src/v1/payments/payments.service.ts | 733 +++++++++++++++--- src/v1/payments/payments.types.ts | 75 +- src/v1/payments/stripe.service.spec.ts | 4 +- src/v1/payments/stripe.service.ts | 71 +- src/v1/reports/reports.module.ts | 3 +- src/v1/reports/reports.service.spec.ts | 13 + src/v1/reports/reports.service.ts | 20 + supabase/updates/024_billing_seats_v2.sql | 95 +++ supabase/updates/README.md | 5 +- 28 files changed, 1716 insertions(+), 346 deletions(-) create mode 100644 src/v1/auth/guards/staff.guard.spec.ts create mode 100644 src/v1/auth/guards/staff.guard.ts create mode 100644 src/v1/payments/dto/admin-set-billing-mode.dto.ts create mode 100644 src/v1/payments/dto/admin-set-manual-seats.dto.ts create mode 100644 src/v1/payments/dto/admin-set-reporting.dto.ts rename src/v1/payments/dto/{cancel-base.dto.ts => cancel-subscription.dto.ts} (91%) delete mode 100644 src/v1/payments/dto/create-base-checkout.dto.ts create mode 100644 supabase/updates/024_billing_seats_v2.sql diff --git a/database.types.ts b/database.types.ts index 663f176..9be01de 100644 --- a/database.types.ts +++ b/database.types.ts @@ -615,9 +615,13 @@ export type Database = { base_discount_id: string | null base_status: string | null base_subscription_id: string | null + billing_mode: string created_at: string device_seats: number device_subscription_id: string | null + reporting_manual: boolean + reporting_status: string | null + reporting_subscription_id: string | null stripe_customer_id: string | null updated_at: string user_id: string @@ -626,9 +630,13 @@ export type Database = { base_discount_id?: string | null base_status?: string | null base_subscription_id?: string | null + billing_mode?: string created_at?: string device_seats?: number device_subscription_id?: string | null + reporting_manual?: boolean + reporting_status?: string | null + reporting_subscription_id?: string | null stripe_customer_id?: string | null updated_at?: string user_id: string @@ -637,9 +645,13 @@ export type Database = { base_discount_id?: string | null base_status?: string | null base_subscription_id?: string | null + billing_mode?: string created_at?: string device_seats?: number device_subscription_id?: string | null + reporting_manual?: boolean + reporting_status?: string | null + reporting_subscription_id?: string | null stripe_customer_id?: string | null updated_at?: string user_id?: string @@ -659,7 +671,7 @@ export type Database = { created_at: string dev_eui: string | null id: number - stripe_subscription_id: string + stripe_subscription_id: string | null seat_index: number status: string updated_at: string @@ -669,7 +681,7 @@ export type Database = { created_at?: string dev_eui?: string | null id?: number - stripe_subscription_id: string + stripe_subscription_id?: string | null seat_index: number status?: string updated_at?: string @@ -679,7 +691,7 @@ export type Database = { created_at?: string dev_eui?: string | null id?: number - stripe_subscription_id?: string + stripe_subscription_id?: string | null seat_index?: number status?: string updated_at?: string diff --git a/docs/runbook.md b/docs/runbook.md index 17f450c..4c94fc8 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -40,7 +40,7 @@ Deploy the `api` repo. The relevant code (all already committed in Phase 0): | Realtime module deleted | `src/v1/realtime/` (removed), [`app.module.ts`](../src/app.module.ts) | Unauthenticated WebSocket scaffold with no consumers | | TTI webhook fail-closed | [`relay.service.ts`](../src/v1/relay/relay.service.ts) | Previously accepted any caller when the token env was unset | | Server-side staff filtering | [`common/owner-filter.helper.ts`](../src/v1/common/owner-filter.helper.ts), [`locations.service.ts`](../src/v1/locations/locations.service.ts) | @cropwatch.io owner rows must never reach non-staff clients (was client-side hiding only) | -| Stripe/payments module deleted | `src/v1/payments/` (removed), [`app.module.ts`](../src/app.module.ts) | Stripe is no longer used | +| Stripe/payments module deleted | `src/v1/payments/` (removed), [`app.module.ts`](../src/app.module.ts) | Stripe (v1, FDW-based) was no longer used at the time. **Superseded:** billing returned via Stripe Checkout in `010`/`015`/`024` — see `supabase/updates/README.md` | | Device-move hand-over | [`devices.service.ts`](../src/v1/devices/devices.service.ts) (`updateDevice`, `resetDevicePermissionsForMove`) | Moving a device now transfers ownership to the destination location owner, wipes old permission rows, seeds members as Disabled, mover as Admin | > Note: this same deploy also contains the 5-level threshold code @@ -72,7 +72,7 @@ live, the old UI's "Disabled" dropdown writes `4`, which now means Viewer. | Alert badge → new endpoints | [`+layout.server.ts`](../../CropWatch/src/routes/+layout.server.ts), [`lib/api/api.service.ts`](../../CropWatch/src/lib/api/api.service.ts), [`OverviewDrawer.svelte`](../../CropWatch/src/routes/OverviewDrawer.svelte) | Consumes `/v1/rules-new/triggered(+/count)` from Phase 2 | | Device refresh scheduler wiring | [`DashboardCards.svelte`](../../CropWatch/src/lib/components/dashboard/DashboardCards.svelte), [`devices/[dev_eui]/+page.svelte`](../../CropWatch/src/routes/locations/%5Blocation_id%5D/devices/%5Bdev_eui%5D/+page.svelte), [`locations/[location_id]/+page.svelte`](../../CropWatch/src/routes/locations/%5Blocation_id%5D/+page.svelte) | Replaces fixed polling with refetch-on-expiry + backoff; location page gains a live Status column | | Client-side staff filters removed | [`DeviceOwnerPermissionsCard.svelte`](../../CropWatch/src/routes/locations/%5Blocation_id%5D/devices/%5Bdev_eui%5D/DeviceOwnerPermissionsCard.svelte), [`LocationEditPermissions.svelte`](../../CropWatch/src/routes/locations/%5Blocation_id%5D/settings/LocationEditPermissions.svelte) | Filtering moved into the API (Phase 2) | -| Billing/Stripe UI removed | `src/routes/account/billing/` (deleted), [`Header.svelte`](../../CropWatch/src/routes/Header.svelte), [`api.service.ts`](../../CropWatch/src/lib/api/api.service.ts), `.env` | Stripe is no longer used | +| Billing/Stripe UI removed | `src/routes/account/billing/` (deleted), [`Header.svelte`](../../CropWatch/src/routes/Header.svelte), [`api.service.ts`](../../CropWatch/src/lib/api/api.service.ts), `.env` | Stripe (v1) was no longer used at the time. **Superseded:** `/account/billing` returned with the Stripe Checkout integration | | Discord options removed | [`lib/i18n/options.ts`](../../CropWatch/src/lib/i18n/options.ts), [`reports/ReportTemplateForm.svelte`](../../CropWatch/src/routes/reports/ReportTemplateForm.svelte) | Discord delivery no longer offered | ## Phase 5 — API release B (removal) diff --git a/llms.txt b/llms.txt index ff9decb..2350bd7 100644 --- a/llms.txt +++ b/llms.txt @@ -1,6 +1,6 @@ # CropWatch API -> CropWatch's REST + WebSocket API for authenticated agricultural device monitoring, automation, and subscription billing. Built with NestJS, backed by Supabase (Postgres + Auth), and integrated with Polar for payments and TTI (The Things Industries) for LoRaWAN device messaging. +> CropWatch's REST + WebSocket API for authenticated agricultural device monitoring, automation, and subscription billing. Built with NestJS, backed by Supabase (Postgres + Auth), and integrated with Stripe for subscription billing and TTI (The Things Industries) for LoRaWAN device messaging. This file is the LLM-oriented contract for the API. It is intended to be loaded by tools and agents in other projects (e.g. the CWUI frontend at `../CWUI`) so they can call the API correctly without fetching the Swagger JSON first. When this file and the live OpenAPI spec disagree, the OpenAPI spec is authoritative — see "Authoritative sources" below. @@ -178,15 +178,30 @@ Scheduled reports with recipients, alert points, and data-processing schedules. `CreateReportDto` required: `dev_eui`, `name`. Optional: `data_pull_interval` (minutes), `report_id` (uuid str), and the four nested arrays `report_user_schedule[]`, `report_alert_points[]`, `report_recipients[]`, `report_data_processing_schedules[]`. Each nested type lives in `src/v1/reports/dto/`. -### Payments — `/v1/payments` (Polar) +### Payments — `/v1/payments` (Stripe) +Billing model: one **device subscription** (per-seat, minimum 3 seats; one `device_licenses` row per seat, a seat is attached to at most one device) plus an optional flat **reporting add-on**. Stripe is the source of truth; `billing_customers` caches state. `billing_mode='manual'` customers are invoiced outside Stripe and get seats/reporting granted by staff. + | Method | Path | Auth | Body / Notes | |---|---|---|---| -| POST | `/payments/subscriptions/checkout` | JWT | `CreateCheckoutSessionDto` `{ products: string[], success_url?, return_url?, customer_name?, customer_email?, customer_billing_address?, metadata?, customer_metadata?, allow_discount_codes?, allow_trial? }` — returns Polar checkout URL. | -| GET | `/payments/subscriptions` | JWT | List subscriptions for the user. | -| GET | `/payments/products` | JWT | List Polar products. | -| GET | `/payments/subscriptions/state` | JWT | Customer subscription state (active/past_due/etc.). | -| POST | `/payments/subscriptions/portal` | JWT | `CreateCustomerPortalSessionDto` `{ return_url? }` — returns Polar customer portal URL. | -| DELETE | `/payments/subscriptions/:id` | JWT | Cancel/revoke subscription. | +| GET | `/payments/products` | JWT | Device-seat + reporting products/prices. | +| GET | `/payments/subscriptions/state` | JWT | Full billing overview `{ billingMode, device, reporting, licenses }`. | +| GET | `/payments/entitlements` | JWT | Cheap DB-only `{ billingMode, isStaff, seats, reporting }`. | +| GET | `/payments/licenses` | JWT | The user's licenses (seats). | +| POST | `/payments/subscriptions/device/checkout` | JWT | `{ quantity >= 3 }` → hosted checkout URL. | +| PATCH | `/payments/subscriptions/device/seats` | JWT | `{ seats >= 3 }` absolute seat count. | +| DELETE | `/payments/subscriptions/device` | JWT | `{ atPeriodEnd? }` cancel the device subscription. | +| POST | `/payments/subscriptions/reporting/checkout` | JWT | Hosted checkout for the reporting add-on. | +| DELETE | `/payments/subscriptions/reporting` | JWT | `{ atPeriodEnd? }` cancel the reporting add-on. | +| POST | `/payments/licenses/:id/assign` | JWT | `{ devEui }` | +| PATCH | `/payments/licenses/:id/move` | JWT | `{ devEui }` | +| POST | `/payments/licenses/:id/unassign` | JWT | Frees the seat. | +| POST | `/payments/licenses/:id/cancel` | JWT | Drops one unassigned seat (never below 3). | +| POST | `/payments/portal` | JWT | Stripe billing portal URL. | +| GET | `/payments/admin/customers` | JWT + staff | Every owner/customer with device, license, subscription counts. | +| PATCH | `/payments/admin/customers/:userId/billing-mode` | JWT + staff | `{ billingMode: 'stripe' \| 'manual' }` | +| PUT | `/payments/admin/customers/:userId/manual-seats` | JWT + staff | `{ seats }` staff-granted seat count. | +| PATCH | `/payments/admin/customers/:userId/reporting` | JWT + staff | `{ manual: boolean }` staff-granted reporting. | +| POST | `/payments/webhook` | Stripe signature | `checkout.session.completed`, `customer.subscription.created/updated/deleted`. | ### Power — `/v1/power` | Method | Path | Auth | Notes | @@ -229,7 +244,7 @@ src/ gateway/ # LoRaWAN gateways rules/ # threshold rules + criteria reports/ # scheduled reports + recipients + schedules - payments/ # Polar checkout/portal/subscriptions + payments/ # Stripe checkout/portal/seats/webhook power/ # placeholder realtime/ # Socket.IO gateway common/ # shared DTOs (ErrorResponseDto), TimezoneFormatterService diff --git a/scripts/stripe-bootstrap.mjs b/scripts/stripe-bootstrap.mjs index 6884f2d..73576d7 100644 --- a/scripts/stripe-bootstrap.mjs +++ b/scripts/stripe-bootstrap.mjs @@ -1,16 +1,27 @@ // Idempotent Stripe product/price bootstrap for CropWatch billing. // -// Creates the Base Subscription (¥15,000/mo) and Device Subscription -// (¥800/seat/mo) products with the lookup keys the API resolves at runtime -// (see src/v1/payments/stripe.service.ts). Safe to re-run: existing prices -// are found by lookup key and left untouched. +// Creates the Device Subscription (per-seat, minimum 3 seats) and Reporting +// add-on (flat monthly) products with the lookup keys the API resolves at +// runtime (see src/v1/payments/stripe.service.ts). Safe to re-run: existing +// prices are found by lookup key and left untouched. +// +// Amounts/currency are read from the environment so the same script serves +// test and live mode. A Stripe price's currency and amount cannot be edited +// after creation — to change pricing later, create a new price in the +// dashboard and transfer the lookup key to it. +// +// STRIPE_BOOTSTRAP_CURRENCY default 'jpy' (zero-decimal: 800 = ¥800) +// STRIPE_BOOTSTRAP_SEAT_AMOUNT default 800 (per seat, per month) +// STRIPE_BOOTSTRAP_REPORTING_AMOUNT default 4000 (flat, per month) +// STRIPE_BOOTSTRAP_TAX_BEHAVIOR default 'inclusive' // // Run against whichever mode the key in STRIPE_SECRET_KEY selects: // node --env-file=.env scripts/stripe-bootstrap.mjs import Stripe from 'stripe'; -const BASE_LOOKUP_KEY = 'cropwatch_base_monthly'; const DEVICE_LOOKUP_KEY = 'cropwatch_device_seat_monthly'; +const REPORTING_LOOKUP_KEY = 'cropwatch_reporting_monthly'; +const SEAT_MINIMUM = 3; // mirrors SEAT_MINIMUM in src/v1/payments/payments.types.ts const secretKey = process.env.STRIPE_SECRET_KEY; if (!secretKey) { @@ -22,15 +33,39 @@ if (!secretKey) { const mode = secretKey.startsWith('sk_live_') ? 'LIVE' : 'test'; const stripe = new Stripe(secretKey); +const currency = (process.env.STRIPE_BOOTSTRAP_CURRENCY ?? 'jpy').toLowerCase(); +const seatAmount = Number.parseInt( + process.env.STRIPE_BOOTSTRAP_SEAT_AMOUNT ?? '800', + 10, +); +const reportingAmount = Number.parseInt( + process.env.STRIPE_BOOTSTRAP_REPORTING_AMOUNT ?? '4000', + 10, +); +const taxBehavior = process.env.STRIPE_BOOTSTRAP_TAX_BEHAVIOR ?? 'inclusive'; +if (!Number.isInteger(seatAmount) || !Number.isInteger(reportingAmount)) { + console.error( + 'STRIPE_BOOTSTRAP_*_AMOUNT must be integers in the smallest currency unit.', + ); + process.exit(1); +} + /** Find an active recurring price by lookup key, or create product + price. */ -async function ensurePrice({ lookupKey, productName, description, unitAmount }) { +async function ensurePrice({ + lookupKey, + productName, + description, + unitAmount, +}) { const existing = await stripe.prices.list({ lookup_keys: [lookupKey], active: true, }); if (existing.data.length > 0) { const price = existing.data[0]; - console.log(`✓ ${lookupKey} already exists: ${price.id} (product ${price.product})`); + console.log( + `✓ ${lookupKey} already exists: ${price.id} (product ${price.product}, ${price.unit_amount} ${price.currency}/${price.recurring?.interval})`, + ); return price; } @@ -41,35 +76,49 @@ async function ensurePrice({ lookupKey, productName, description, unitAmount }) const price = await stripe.prices.create({ product: product.id, lookup_key: lookupKey, - currency: 'jpy', // zero-decimal: unit_amount 15000 = ¥15,000 + currency, unit_amount: unitAmount, recurring: { interval: 'month' }, - // Prices are tax-inclusive; JCT accounting is handled outside Stripe. - tax_behavior: 'inclusive', + // Default: prices are tax-inclusive; JCT accounting is handled outside Stripe. + tax_behavior: taxBehavior, }); - console.log(`+ created ${lookupKey}: ${price.id} (product ${product.id})`); + console.log( + `+ created ${lookupKey}: ${price.id} (product ${product.id}, ${unitAmount} ${currency}/month)`, + ); return price; } console.log(`Bootstrapping CropWatch billing products in ${mode} mode…`); - -const base = await ensurePrice({ - lookupKey: BASE_LOOKUP_KEY, - productName: 'Base Subscription', - description: - 'Required CropWatch account subscription. Every account needs one active base subscription.', - unitAmount: 15000, -}); +console.log( + ` currency=${currency} seat=${seatAmount} reporting=${reportingAmount} tax_behavior=${taxBehavior}`, +); +if (mode === 'LIVE') { + console.log( + ' !! LIVE mode: double-check the amounts above — prices cannot be edited later.', + ); +} const device = await ensurePrice({ lookupKey: DEVICE_LOOKUP_KEY, productName: 'Device Subscription', + description: `Per-device license. One seat = one device license (minimum ${SEAT_MINIMUM} seats). Assign licenses to devices in CropWatch.`, + unitAmount: seatAmount, +}); + +const reporting = await ensurePrice({ + lookupKey: REPORTING_LOOKUP_KEY, + productName: 'Reporting Package', description: - 'Per-device license. One seat = one device license. Assign licenses to devices in CropWatch.', - unitAmount: 800, + 'Scheduled PDF/email reports for every device on the account. One flat monthly add-on.', + unitAmount: reportingAmount, }); console.log('\nDone. The API resolves these automatically by lookup key —'); -console.log('no STRIPE_BASE_PRICE_ID / STRIPE_DEVICE_PRICE_ID env vars needed.'); -console.log(` base: ${base.id}`); -console.log(` device: ${device.id}`); +console.log( + 'no STRIPE_DEVICE_PRICE_ID / STRIPE_REPORTING_PRICE_ID env vars needed.', +); +console.log(` device: ${device.id}`); +console.log(` reporting: ${reporting.id}`); +console.log( + `\nThe minimum seat count (${SEAT_MINIMUM}) is enforced by the API and the hosted checkout, not by the price.`, +); diff --git a/src/v1/auth/guards/staff.guard.spec.ts b/src/v1/auth/guards/staff.guard.spec.ts new file mode 100644 index 0000000..9de9aa9 --- /dev/null +++ b/src/v1/auth/guards/staff.guard.spec.ts @@ -0,0 +1,34 @@ +import { ForbiddenException } from '@nestjs/common'; +import type { ExecutionContext } from '@nestjs/common'; +import { StaffGuard } from './staff.guard'; + +const contextFor = (user: unknown): ExecutionContext => + ({ + switchToHttp: () => ({ getRequest: () => ({ user }) }), + }) as unknown as ExecutionContext; + +describe('StaffGuard', () => { + const guard = new StaffGuard(); + + it('allows staff users', () => { + expect( + guard.canActivate( + contextFor({ sub: 'u1', email: 'a@cropwatch.io', isStaff: true }), + ), + ).toBe(true); + }); + + it('rejects non-staff users', () => { + expect(() => + guard.canActivate( + contextFor({ sub: 'u1', email: 'a@example.com', isStaff: false }), + ), + ).toThrow(ForbiddenException); + }); + + it('rejects requests with no authenticated user', () => { + expect(() => guard.canActivate(contextFor(undefined))).toThrow( + ForbiddenException, + ); + }); +}); diff --git a/src/v1/auth/guards/staff.guard.ts b/src/v1/auth/guards/staff.guard.ts new file mode 100644 index 0000000..9290670 --- /dev/null +++ b/src/v1/auth/guards/staff.guard.ts @@ -0,0 +1,27 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import type { Request } from 'express'; +import type { AuthenticatedUser } from '../authenticated-user'; + +/** + * Restricts a route to CropWatch staff. Must run AFTER {@link JwtAuthGuard}, + * which attaches the validated {@link AuthenticatedUser} to `request.user`: + * + * @UseGuards(JwtAuthGuard, StaffGuard) + */ +@Injectable() +export class StaffGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context + .switchToHttp() + .getRequest(); + if (!request.user?.isStaff) { + throw new ForbiddenException('Staff only'); + } + return true; + } +} diff --git a/src/v1/devices/devices.service.ts b/src/v1/devices/devices.service.ts index f83c26d..4795d14 100644 --- a/src/v1/devices/devices.service.ts +++ b/src/v1/devices/devices.service.ts @@ -108,7 +108,8 @@ export class DevicesService { *, owner_match:cw_device_owners(), cw_device_owners(*), - cw_locations(name, location_id) + cw_locations(name, location_id), + device_licenses(id) `, { count: 'exact' }, ); diff --git a/src/v1/locations/locations.module.ts b/src/v1/locations/locations.module.ts index 0662cb5..9c481d6 100644 --- a/src/v1/locations/locations.module.ts +++ b/src/v1/locations/locations.module.ts @@ -2,10 +2,9 @@ import { Module } from '@nestjs/common'; import { LocationsService } from './locations.service'; import { LocationsController } from './locations.controller'; import { SupabaseModule } from '../../supabase/supabase.module'; -import { PaymentsModule } from '../payments/payments.module'; @Module({ - imports: [SupabaseModule, PaymentsModule], + imports: [SupabaseModule], controllers: [LocationsController], providers: [LocationsService], exports: [LocationsService], diff --git a/src/v1/locations/locations.service.spec.ts b/src/v1/locations/locations.service.spec.ts index e218205..9f487fa 100644 --- a/src/v1/locations/locations.service.spec.ts +++ b/src/v1/locations/locations.service.spec.ts @@ -4,7 +4,6 @@ import { } from '@nestjs/common'; import { LocationsService } from './locations.service'; import { SupabaseService } from '../../supabase/supabase.service'; -import { PaymentsService } from '../payments/payments.service'; describe('LocationsService', () => { type QueryResult = { data: unknown; error: unknown }; @@ -57,15 +56,10 @@ describe('LocationsService', () => { }); const createService = (client: ReturnType) => - new LocationsService( - { - getClient: jest.fn(() => client), - getAdminClient: jest.fn(), - } as unknown as SupabaseService, - { - hasActiveBaseSubscription: jest.fn(() => Promise.resolve(true)), - } as unknown as PaymentsService, - ); + new LocationsService({ + getClient: jest.fn(() => client), + getAdminClient: jest.fn(), + } as unknown as SupabaseService); it('should be defined', () => { const client = createClient({}); diff --git a/src/v1/locations/locations.service.ts b/src/v1/locations/locations.service.ts index 6d82d86..ff8c5c5 100644 --- a/src/v1/locations/locations.service.ts +++ b/src/v1/locations/locations.service.ts @@ -1,5 +1,4 @@ import { - ForbiddenException, Injectable, InternalServerErrorException, NotFoundException, @@ -10,7 +9,6 @@ import { CreateLocationDto } from './dto/create-location.dto'; import { CreateLocationOwnerDto } from './dto/create-location-owner.dto'; import { UpdateLocationDto } from './dto/update-location.dto'; import { SupabaseService } from '../../supabase/supabase.service'; -import { PaymentsService } from '../payments/payments.service'; import { LocationDto } from './dto/location.dto'; import { UpdateLocationOwnerDto } from './dto/update-location-owner.dto'; import { @@ -46,26 +44,12 @@ interface LocationScopeQuery { @Injectable() export class LocationsService { - constructor( - private readonly supabaseService: SupabaseService, - private readonly paymentsService: PaymentsService, - ) {} + constructor(private readonly supabaseService: SupabaseService) {} async create(createLocationDto: CreateLocationDto, user: AuthenticatedUser) { const userId = user.sub; const client = this.supabaseService.getClient(); - // Creating a location requires an active base subscription. CropWatch staff - // are exempt, mirroring the rest of the permission model. - if ( - !user.isStaff && - !(await this.paymentsService.hasActiveBaseSubscription(user)) - ) { - throw new ForbiddenException( - 'An active base subscription is required to create a location.', - ); - } - createLocationDto.owner_id = userId; // Ensure the owner_id is set to the authenticated user const { data: locationData, error: locationError } = (await client diff --git a/src/v1/payments/dto/admin-set-billing-mode.dto.ts b/src/v1/payments/dto/admin-set-billing-mode.dto.ts new file mode 100644 index 0000000..00d6e51 --- /dev/null +++ b/src/v1/payments/dto/admin-set-billing-mode.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsIn } from 'class-validator'; +import type { BillingMode } from '../payments.types'; + +export class AdminSetBillingModeDto { + @ApiProperty({ + enum: ['stripe', 'manual'], + description: + "'stripe' = self-serve Stripe subscriptions; 'manual' = invoiced outside Stripe, seats granted by staff.", + }) + @IsIn(['stripe', 'manual']) + billingMode: BillingMode; +} diff --git a/src/v1/payments/dto/admin-set-manual-seats.dto.ts b/src/v1/payments/dto/admin-set-manual-seats.dto.ts new file mode 100644 index 0000000..94ef284 --- /dev/null +++ b/src/v1/payments/dto/admin-set-manual-seats.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, Min } from 'class-validator'; + +export class AdminSetManualSeatsDto { + @ApiProperty({ + description: + 'Absolute number of staff-granted device licenses the user should have. Cannot go below the number of assigned staff-granted licenses.', + minimum: 0, + example: 3, + }) + @Type(() => Number) + @IsInt() + @Min(0) + seats: number; +} diff --git a/src/v1/payments/dto/admin-set-reporting.dto.ts b/src/v1/payments/dto/admin-set-reporting.dto.ts new file mode 100644 index 0000000..43b3384 --- /dev/null +++ b/src/v1/payments/dto/admin-set-reporting.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsBoolean } from 'class-validator'; + +export class AdminSetReportingDto { + @ApiProperty({ + description: + 'Grant (true) or revoke (false) the staff-granted reporting entitlement. Independent of any Stripe reporting subscription.', + }) + @IsBoolean() + manual: boolean; +} diff --git a/src/v1/payments/dto/cancel-base.dto.ts b/src/v1/payments/dto/cancel-subscription.dto.ts similarity index 91% rename from src/v1/payments/dto/cancel-base.dto.ts rename to src/v1/payments/dto/cancel-subscription.dto.ts index 880e4e4..474ae97 100644 --- a/src/v1/payments/dto/cancel-base.dto.ts +++ b/src/v1/payments/dto/cancel-subscription.dto.ts @@ -2,7 +2,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsBoolean, IsOptional } from 'class-validator'; -export class CancelBaseDto { +export class CancelSubscriptionDto { @ApiProperty({ required: false, default: true, diff --git a/src/v1/payments/dto/change-seats.dto.ts b/src/v1/payments/dto/change-seats.dto.ts index 9999790..5a87320 100644 --- a/src/v1/payments/dto/change-seats.dto.ts +++ b/src/v1/payments/dto/change-seats.dto.ts @@ -1,16 +1,16 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsInt, Min } from 'class-validator'; +import { SEAT_MINIMUM } from '../payments.types'; export class ChangeSeatsDto { @ApiProperty({ - description: - 'Absolute target number of device licenses (seats). Must be at least the number of currently assigned licenses.', - minimum: 0, - example: 3, + description: `Absolute target number of device licenses (seats). Must be at least ${SEAT_MINIMUM} and at least the number of currently assigned licenses. To go lower, cancel the device subscription instead.`, + minimum: SEAT_MINIMUM, + example: 5, }) @Type(() => Number) @IsInt() - @Min(0) + @Min(SEAT_MINIMUM) seats: number; } diff --git a/src/v1/payments/dto/create-base-checkout.dto.ts b/src/v1/payments/dto/create-base-checkout.dto.ts deleted file mode 100644 index 9518f1c..0000000 --- a/src/v1/payments/dto/create-base-checkout.dto.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsOptional, IsString } from 'class-validator'; - -export class CreateBaseCheckoutDto { - @ApiProperty({ - required: false, - nullable: true, - description: - 'Optional Stripe promotion code id (promo_...) to apply to the base subscription.', - }) - @IsOptional() - @IsString() - discountId?: string | null; -} diff --git a/src/v1/payments/dto/create-device-checkout.dto.ts b/src/v1/payments/dto/create-device-checkout.dto.ts index 773e0f5..f022c18 100644 --- a/src/v1/payments/dto/create-device-checkout.dto.ts +++ b/src/v1/payments/dto/create-device-checkout.dto.ts @@ -1,15 +1,16 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsInt, Min } from 'class-validator'; +import { SEAT_MINIMUM } from '../payments.types'; export class CreateDeviceCheckoutDto { @ApiProperty({ - description: 'Number of device licenses (seats) to purchase initially.', - minimum: 1, - example: 1, + description: `Number of device licenses (seats) to purchase initially. Minimum ${SEAT_MINIMUM}.`, + minimum: SEAT_MINIMUM, + example: SEAT_MINIMUM, }) @Type(() => Number) @IsInt() - @Min(1) + @Min(SEAT_MINIMUM) quantity: number; } diff --git a/src/v1/payments/payments.controller.ts b/src/v1/payments/payments.controller.ts index 37f8d15..7238eca 100644 --- a/src/v1/payments/payments.controller.ts +++ b/src/v1/payments/payments.controller.ts @@ -7,8 +7,10 @@ import { HttpCode, HttpStatus, Param, + ParseUUIDPipe, Patch, Post, + Put, Req, UseGuards, } from '@nestjs/common'; @@ -22,15 +24,18 @@ import { } from '@nestjs/swagger'; import { SkipThrottle } from '@nestjs/throttler'; import { JwtAuthGuard } from '../auth/guards/jwt.auth.guard'; +import { StaffGuard } from '../auth/guards/staff.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import type { AuthenticatedUser } from '../auth/authenticated-user'; import { PaymentsService } from './payments.service'; -import { CreateBaseCheckoutDto } from './dto/create-base-checkout.dto'; import { CreateDeviceCheckoutDto } from './dto/create-device-checkout.dto'; import { ChangeSeatsDto } from './dto/change-seats.dto'; import { AssignLicenseDto } from './dto/assign-license.dto'; import { MoveLicenseDto } from './dto/move-license.dto'; -import { CancelBaseDto } from './dto/cancel-base.dto'; +import { CancelSubscriptionDto } from './dto/cancel-subscription.dto'; +import { AdminSetBillingModeDto } from './dto/admin-set-billing-mode.dto'; +import { AdminSetManualSeatsDto } from './dto/admin-set-manual-seats.dto'; +import { AdminSetReportingDto } from './dto/admin-set-reporting.dto'; @ApiBearerAuth('bearerAuth') @ApiSecurity('apiKey') @@ -38,9 +43,15 @@ import { CancelBaseDto } from './dto/cancel-base.dto'; export class PaymentsController { constructor(private readonly paymentsService: PaymentsService) {} + // --------------------------------------------------------------------------- + // Reads + // --------------------------------------------------------------------------- + @Get('products') @UseGuards(JwtAuthGuard) - @ApiOperation({ summary: 'List the base and device subscription products' }) + @ApiOperation({ + summary: 'List the device-seat and reporting subscription products', + }) getProducts() { return this.paymentsService.getProducts(); } @@ -48,12 +59,23 @@ export class PaymentsController { @Get('subscriptions/state') @UseGuards(JwtAuthGuard) @ApiOperation({ - summary: 'Get the full billing overview (base sub, device seats, licenses)', + summary: + 'Get the full billing overview (billing mode, device seats, reporting, licenses)', }) getState(@CurrentUser() user: AuthenticatedUser) { return this.paymentsService.getState(user); } + @Get('entitlements') + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: + 'Cheap entitlement summary (seat count, reporting access) without calling Stripe', + }) + getEntitlements(@CurrentUser() user: AuthenticatedUser) { + return this.paymentsService.getEntitlements(user); + } + @Get('licenses') @UseGuards(JwtAuthGuard) @ApiOperation({ summary: "List the user's device licenses" }) @@ -61,25 +83,14 @@ export class PaymentsController { return this.paymentsService.getLicenses(user); } - @Post('subscriptions/base/checkout') - @UseGuards(JwtAuthGuard) - @ApiOperation({ - summary: 'Create a hosted checkout for the base subscription', - }) - createBaseCheckout( - @Body() dto: CreateBaseCheckoutDto, - @CurrentUser() user: AuthenticatedUser, - ) { - return this.paymentsService.createBaseCheckout( - user, - dto.discountId ?? null, - ); - } + // --------------------------------------------------------------------------- + // Device seats + // --------------------------------------------------------------------------- @Post('subscriptions/device/checkout') @UseGuards(JwtAuthGuard) @ApiOperation({ - summary: 'Create a hosted checkout for device licenses (seats)', + summary: 'Create a hosted checkout for device licenses (seats, min 3)', }) createDeviceCheckout( @Body() dto: CreateDeviceCheckoutDto, @@ -98,6 +109,51 @@ export class PaymentsController { return this.paymentsService.changeDeviceSeats(user, dto.seats); } + @Delete('subscriptions/device') + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: 'Cancel the device subscription (all seats)', + }) + cancelDeviceSubscription( + @Body() dto: CancelSubscriptionDto, + @CurrentUser() user: AuthenticatedUser, + ) { + return this.paymentsService.cancelDeviceSubscription( + user, + dto.atPeriodEnd ?? true, + ); + } + + // --------------------------------------------------------------------------- + // Reporting add-on + // --------------------------------------------------------------------------- + + @Post('subscriptions/reporting/checkout') + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: 'Create a hosted checkout for the reporting add-on', + }) + createReportingCheckout(@CurrentUser() user: AuthenticatedUser) { + return this.paymentsService.createReportingCheckout(user); + } + + @Delete('subscriptions/reporting') + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Cancel the reporting add-on subscription' }) + cancelReportingSubscription( + @Body() dto: CancelSubscriptionDto, + @CurrentUser() user: AuthenticatedUser, + ) { + return this.paymentsService.cancelReportingSubscription( + user, + dto.atPeriodEnd ?? true, + ); + } + + // --------------------------------------------------------------------------- + // Licenses + // --------------------------------------------------------------------------- + @Post('licenses/:id/assign') @UseGuards(JwtAuthGuard) @ApiParam({ name: 'id', description: 'License id', type: Number }) @@ -141,7 +197,8 @@ export class PaymentsController { @UseGuards(JwtAuthGuard) @ApiParam({ name: 'id', description: 'License id', type: Number }) @ApiOperation({ - summary: 'Cancel an unassigned license (reduce the paid seat count by one)', + summary: + 'Cancel an unassigned license (reduce the paid seat count by one, never below the minimum)', }) cancelLicense( @Param('id') id: string, @@ -157,19 +214,62 @@ export class PaymentsController { return this.paymentsService.openPortal(user); } - @Delete('subscriptions/base') - @UseGuards(JwtAuthGuard) - @ApiOperation({ summary: 'Cancel the base subscription' }) - cancelBase( - @Body() dto: CancelBaseDto, - @CurrentUser() user: AuthenticatedUser, + // --------------------------------------------------------------------------- + // Staff administration (manual-invoice customers, legacy device visibility) + // --------------------------------------------------------------------------- + + @Get('admin/customers') + @UseGuards(JwtAuthGuard, StaffGuard) + @ApiOperation({ + summary: + 'Staff: list every device owner / billing customer with device, license, and subscription counts', + }) + adminListCustomers() { + return this.paymentsService.adminListCustomers(); + } + + @Patch('admin/customers/:userId/billing-mode') + @UseGuards(JwtAuthGuard, StaffGuard) + @ApiParam({ name: 'userId', description: 'Profile id (uuid)' }) + @ApiOperation({ summary: "Staff: switch a customer's billing mode" }) + adminSetBillingMode( + @Param('userId', new ParseUUIDPipe()) userId: string, + @Body() dto: AdminSetBillingModeDto, ) { - return this.paymentsService.cancelBaseSubscription( - user, - dto.atPeriodEnd ?? true, - ); + return this.paymentsService.adminSetBillingMode(userId, dto.billingMode); } + @Put('admin/customers/:userId/manual-seats') + @UseGuards(JwtAuthGuard, StaffGuard) + @ApiParam({ name: 'userId', description: 'Profile id (uuid)' }) + @ApiOperation({ + summary: + 'Staff: set the number of staff-granted device licenses for a manual-invoice customer', + }) + adminSetManualSeats( + @Param('userId', new ParseUUIDPipe()) userId: string, + @Body() dto: AdminSetManualSeatsDto, + ) { + return this.paymentsService.adminSetManualSeats(userId, dto.seats); + } + + @Patch('admin/customers/:userId/reporting') + @UseGuards(JwtAuthGuard, StaffGuard) + @ApiParam({ name: 'userId', description: 'Profile id (uuid)' }) + @ApiOperation({ + summary: 'Staff: grant or revoke the reporting entitlement for a customer', + }) + adminSetReportingManual( + @Param('userId', new ParseUUIDPipe()) userId: string, + @Body() dto: AdminSetReportingDto, + ) { + return this.paymentsService.adminSetReportingManual(userId, dto.manual); + } + + // --------------------------------------------------------------------------- + // Webhook + // --------------------------------------------------------------------------- + // Signature-verified in the service and driven by Stripe's own retrying // delivery from a small set of provider IPs — exempt from the per-user/IP // throttler so a retry burst can't get billing events dropped with a 429. diff --git a/src/v1/payments/payments.service.spec.ts b/src/v1/payments/payments.service.spec.ts index 17af01c..8421a9e 100644 --- a/src/v1/payments/payments.service.spec.ts +++ b/src/v1/payments/payments.service.spec.ts @@ -1,4 +1,5 @@ import { + BadRequestException, ConflictException, ForbiddenException, UnauthorizedException, @@ -8,8 +9,8 @@ import { PaymentsService } from './payments.service'; import { SupabaseService } from '../../supabase/supabase.service'; import { StripeService, BillingSubscriptionInfo } from './stripe.service'; -const BASE_PRICE = 'price_base'; const DEVICE_PRICE = 'price_device'; +const REPORTING_PRICE = 'price_reporting'; describe('PaymentsService', () => { type QueryResult = { data: unknown; error: unknown }; @@ -22,6 +23,7 @@ describe('PaymentsService', () => { neq: jest.Mock; in: jest.Mock; is: jest.Mock; + not: jest.Mock; or: jest.Mock; lte: jest.Mock; order: jest.Mock; @@ -42,6 +44,7 @@ describe('PaymentsService', () => { neq: jest.fn(() => builder), in: jest.fn(() => builder), is: jest.fn(() => builder), + not: jest.fn(() => builder), or: jest.fn(() => builder), lte: jest.fn(() => builder), order: jest.fn(() => builder), @@ -84,7 +87,10 @@ describe('PaymentsService', () => { overrides: Partial = {}, ): StripeServiceMock => ({ resolvePriceIds: jest.fn(() => - Promise.resolve({ basePriceId: BASE_PRICE, devicePriceId: DEVICE_PRICE }), + Promise.resolve({ + devicePriceId: DEVICE_PRICE, + reportingPriceId: REPORTING_PRICE, + }), ), isWebhookConfigured: true, listSubscriptions: jest.fn(() => Promise.resolve([])), @@ -115,6 +121,26 @@ describe('PaymentsService', () => { ); const user = { sub: 'user-1', email: 'kevin@example.com', isStaff: false }; + const staff = { sub: 'staff-1', email: 'ops@cropwatch.io', isStaff: true }; + + const stripeCustomer = (overrides: Record = {}) => ({ + user_id: 'user-1', + stripe_customer_id: 'cus_1', + billing_mode: 'stripe', + device_subscription_id: null, + device_seats: 0, + reporting_subscription_id: null, + reporting_status: null, + reporting_manual: false, + ...overrides, + }); + + const manualCustomer = (overrides: Record = {}) => + stripeCustomer({ + stripe_customer_id: null, + billing_mode: 'manual', + ...overrides, + }); const deviceSub = ( overrides: Partial = {}, @@ -129,6 +155,29 @@ describe('PaymentsService', () => { ...overrides, }); + const reportingSub = ( + overrides: Partial = {}, + ): BillingSubscriptionInfo => + deviceSub({ + id: 'sub_reporting', + priceId: REPORTING_PRICE, + seats: 1, + ...overrides, + }); + + const seatRow = ( + id: number, + seatIndex: number, + overrides: Record = {}, + ) => ({ + id, + seat_index: seatIndex, + status: 'unassigned', + dev_eui: null, + stripe_subscription_id: 'sub_device', + ...overrides, + }); + const webhookEvent = (type: string, object: unknown) => ({ type, data: { object } }) as Stripe.Event; @@ -169,11 +218,21 @@ describe('PaymentsService', () => { metadata: { user_id: 'user-1' }, }; + const updatedEvent = (seats: number) => + createStripeMock({ + constructWebhookEvent: jest.fn(() => + webhookEvent('customer.subscription.updated', subscriptionPayload), + ), + retrieveSubscription: jest.fn(() => + Promise.resolve(deviceSub({ seats })), + ), + }); + it('subscription.updated adds unassigned license rows up to the paid seat count', async () => { const linkUpsert = createBuilder({ data: null, error: null }); const cachePatch = createBuilder({ data: null, error: null }); const licenseSelect = createBuilder({ - data: [{ id: 11, seat_index: 0, status: 'assigned', dev_eui: 'EUI-1' }], + data: [seatRow(11, 0, { status: 'assigned', dev_eui: 'EUI-1' })], error: null, }); const licenseInsert = createBuilder({ data: null, error: null }); @@ -181,15 +240,7 @@ describe('PaymentsService', () => { billing_customers: [linkUpsert, cachePatch], device_licenses: [licenseSelect, licenseInsert], }); - const stripeService = createStripeMock({ - constructWebhookEvent: jest.fn(() => - webhookEvent('customer.subscription.updated', subscriptionPayload), - ), - retrieveSubscription: jest.fn(() => - Promise.resolve(deviceSub({ seats: 3 })), - ), - }); - const service = createService(client, stripeService); + const service = createService(client, updatedEvent(3)); await expect( service.handleWebhook(Buffer.from('{}'), { 'stripe-signature': 'ok' }), @@ -217,9 +268,10 @@ describe('PaymentsService', () => { const cachePatch = createBuilder({ data: null, error: null }); const licenseSelect = createBuilder({ data: [ - { id: 11, seat_index: 0, status: 'assigned', dev_eui: 'EUI-1' }, - { id: 12, seat_index: 1, status: 'unassigned', dev_eui: null }, - { id: 13, seat_index: 2, status: 'unassigned', dev_eui: null }, + seatRow(11, 0, { status: 'assigned', dev_eui: 'EUI-1' }), + seatRow(12, 1), + seatRow(13, 2), + seatRow(14, 3), ], error: null, }); @@ -228,26 +280,78 @@ describe('PaymentsService', () => { billing_customers: [linkUpsert, cachePatch], device_licenses: [licenseSelect, licenseDelete], }); - const stripeService = createStripeMock({ - constructWebhookEvent: jest.fn(() => - webhookEvent('customer.subscription.updated', subscriptionPayload), - ), - retrieveSubscription: jest.fn(() => - Promise.resolve(deviceSub({ seats: 1 })), - ), - }); - const service = createService(client, stripeService); + const service = createService(client, updatedEvent(3)); await service.handleWebhook(Buffer.from('{}'), { 'stripe-signature': 'ok', }); - // Highest-seat-index unassigned rows go first; the assigned row survives. + // Highest-seat-index unassigned row goes first; the assigned row survives. expect(licenseDelete.delete).toHaveBeenCalled(); - expect(licenseDelete.in).toHaveBeenCalledWith('id', [13, 12]); + expect(licenseDelete.in).toHaveBeenCalledWith('id', [14]); + }); + + it('reconcile ignores staff-granted rows but reserves their seat_index', async () => { + const linkUpsert = createBuilder({ data: null, error: null }); + const cachePatch = createBuilder({ data: null, error: null }); + const licenseSelect = createBuilder({ + data: [ + seatRow(1, 0, { stripe_subscription_id: null }), + seatRow(2, 1, { stripe_subscription_id: null }), + seatRow(3, 2), + ], + error: null, + }); + const licenseInsert = createBuilder({ data: null, error: null }); + const client = createClient({ + billing_customers: [linkUpsert, cachePatch], + device_licenses: [licenseSelect, licenseInsert], + }); + const service = createService(client, updatedEvent(3)); + + await service.handleWebhook(Buffer.from('{}'), { + 'stripe-signature': 'ok', + }); + + // 1 Stripe row exists, target 3 → add 2, starting after the highest index. + expect(licenseInsert.insert).toHaveBeenCalledWith([ + expect.objectContaining({ + seat_index: 3, + stripe_subscription_id: 'sub_device', + }), + expect.objectContaining({ + seat_index: 4, + stripe_subscription_id: 'sub_device', + }), + ]); + }); + + it('reconcile re-reads once when a concurrent webhook already inserted the seats', async () => { + const linkUpsert = createBuilder({ data: null, error: null }); + const cachePatch = createBuilder({ data: null, error: null }); + const firstSelect = createBuilder({ data: [], error: null }); + const racedInsert = createBuilder({ + data: null, + error: { code: '23505', message: 'duplicate key' }, + }); + const secondSelect = createBuilder({ + data: [seatRow(1, 0), seatRow(2, 1), seatRow(3, 2)], + error: null, + }); + const client = createClient({ + billing_customers: [linkUpsert, cachePatch], + device_licenses: [firstSelect, racedInsert, secondSelect], + }); + const service = createService(client, updatedEvent(3)); + + await expect( + service.handleWebhook(Buffer.from('{}'), { 'stripe-signature': 'ok' }), + ).resolves.toEqual({ received: true }); + expect(racedInsert.insert).toHaveBeenCalledTimes(1); + expect(secondSelect.select).toHaveBeenCalled(); }); - it('subscription.deleted wipes all licenses and zeroes the seat cache', async () => { + it('subscription.deleted wipes Stripe-backed licenses and zeroes the seat cache', async () => { const linkUpsert = createBuilder({ data: null, error: null }); const cachePatch = createBuilder({ data: null, error: null }); const licenseDelete = createBuilder({ data: null, error: null }); @@ -270,6 +374,12 @@ describe('PaymentsService', () => { expect(licenseDelete.delete).toHaveBeenCalled(); expect(licenseDelete.eq).toHaveBeenCalledWith('user_id', 'user-1'); + // Staff-granted rows (NULL subscription id) are left alone. + expect(licenseDelete.not).toHaveBeenCalledWith( + 'stripe_subscription_id', + 'is', + null, + ); expect(cachePatch.update).toHaveBeenCalledWith( expect.objectContaining({ device_subscription_id: null, @@ -277,7 +387,9 @@ describe('PaymentsService', () => { }), ); }); + }); + describe('reporting subscription webhooks', () => { it('resolves the user via the Stripe customer when metadata and mapping are missing', async () => { const mappingLookup = createBuilder({ data: null, error: null }); const linkUpsert = createBuilder({ data: null, error: null }); @@ -288,16 +400,12 @@ describe('PaymentsService', () => { const stripeService = createStripeMock({ constructWebhookEvent: jest.fn(() => webhookEvent('customer.subscription.updated', { - id: 'sub_base', + id: 'sub_reporting', customer: 'cus_9', metadata: {}, }), ), - retrieveSubscription: jest.fn(() => - Promise.resolve( - deviceSub({ id: 'sub_base', priceId: BASE_PRICE, seats: null }), - ), - ), + retrieveSubscription: jest.fn(() => Promise.resolve(reportingSub())), retrieveCustomerUserId: jest.fn(() => Promise.resolve('user-9')), }); const service = createService(client, stripeService); @@ -311,36 +419,92 @@ describe('PaymentsService', () => { ); expect(cachePatch.update).toHaveBeenCalledWith( expect.objectContaining({ - base_subscription_id: 'sub_base', - base_status: 'active', + reporting_subscription_id: 'sub_reporting', + reporting_status: 'active', }), ); expect(cachePatch.eq).toHaveBeenCalledWith('user_id', 'user-9'); }); + + it('subscription.deleted marks reporting canceled and deactivates the report templates', async () => { + const linkUpsert = createBuilder({ data: null, error: null }); + const cachePatch = createBuilder({ data: null, error: null }); + const templatePatch = createBuilder({ data: null, error: null }); + const client = createClient({ + billing_customers: [linkUpsert, cachePatch], + cw_report_templates: [templatePatch], + }); + const stripeService = createStripeMock({ + constructWebhookEvent: jest.fn(() => + webhookEvent('customer.subscription.deleted', { + id: 'sub_reporting', + customer: 'cus_1', + metadata: { user_id: 'user-1' }, + }), + ), + toSubscriptionInfo: jest.fn(() => reportingSub({ status: 'canceled' })), + }); + const service = createService(client, stripeService); + + await service.handleWebhook(Buffer.from('{}'), { + 'stripe-signature': 'ok', + }); + + expect(cachePatch.update).toHaveBeenCalledWith( + expect.objectContaining({ + reporting_subscription_id: null, + reporting_status: 'canceled', + }), + ); + expect(templatePatch.update).toHaveBeenCalledWith({ is_active: false }); + expect(templatePatch.eq).toHaveBeenCalledWith('created_by', 'user-1'); + }); }); describe('seat and checkout guards', () => { + it('changeDeviceSeats rejects going below the seat minimum before calling Stripe', async () => { + const customerSelect = createBuilder({ + data: stripeCustomer(), + error: null, + }); + const client = createClient({ billing_customers: [customerSelect] }); + const stripeService = createStripeMock(); + const service = createService(client, stripeService); + + await expect(service.changeDeviceSeats(user, 2)).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(stripeService.listSubscriptions).not.toHaveBeenCalled(); + expect(stripeService.updateSeats).not.toHaveBeenCalled(); + }); + it('changeDeviceSeats rejects reducing below the assigned license count', async () => { const customerSelect = createBuilder({ - data: { user_id: 'user-1', stripe_customer_id: 'cus_1' }, + data: stripeCustomer(), error: null, }); const licenseSelect = createBuilder({ data: [ - { - id: 11, - seat_index: 0, + seatRow(11, 0, { status: 'assigned', dev_eui: 'EUI-1', cw_devices: { name: 'Sensor A' }, - }, - { - id: 12, - seat_index: 1, + }), + seatRow(12, 1, { status: 'assigned', dev_eui: 'EUI-2', cw_devices: { name: 'Sensor B' }, - }, + }), + seatRow(13, 2, { + status: 'assigned', + dev_eui: 'EUI-3', + cw_devices: { name: 'Sensor C' }, + }), + seatRow(14, 3, { + status: 'assigned', + dev_eui: 'EUI-4', + cw_devices: { name: 'Sensor D' }, + }), ], error: null, }); @@ -349,50 +513,50 @@ describe('PaymentsService', () => { device_licenses: [licenseSelect], }); const stripeService = createStripeMock({ - listSubscriptions: jest.fn(() => Promise.resolve([deviceSub()])), + listSubscriptions: jest.fn(() => + Promise.resolve([deviceSub({ seats: 4 })]), + ), }); const service = createService(client, stripeService); - await expect(service.changeDeviceSeats(user, 1)).rejects.toBeInstanceOf( + await expect(service.changeDeviceSeats(user, 3)).rejects.toBeInstanceOf( ConflictException, ); expect(stripeService.updateSeats).not.toHaveBeenCalled(); }); - it('createBaseCheckout rejects when a base subscription is already active', async () => { + it('createDeviceCheckout rejects manual-invoice customers', async () => { const customerSelect = createBuilder({ - data: { user_id: 'user-1', stripe_customer_id: 'cus_1' }, + data: manualCustomer(), error: null, }); const client = createClient({ billing_customers: [customerSelect] }); - const stripeService = createStripeMock({ - listSubscriptions: jest.fn(() => - Promise.resolve([ - deviceSub({ id: 'sub_base', priceId: BASE_PRICE, seats: null }), - ]), - ), - }); + const stripeService = createStripeMock(); const service = createService(client, stripeService); - await expect(service.createBaseCheckout(user)).rejects.toBeInstanceOf( - ConflictException, - ); + await expect( + service.createDeviceCheckout(user, 3), + ).rejects.toBeInstanceOf(BadRequestException); expect(stripeService.createCheckout).not.toHaveBeenCalled(); }); - it('createBaseCheckout lazily creates the Stripe customer on first use', async () => { + it('createDeviceCheckout lazily creates the Stripe customer and enforces the seat minimum on the hosted page', async () => { const customerSelect = createBuilder({ - data: { user_id: 'user-1', stripe_customer_id: null }, + data: stripeCustomer({ stripe_customer_id: null }), + error: null, + }); + const customerSelectAgain = createBuilder({ + data: stripeCustomer({ stripe_customer_id: null }), error: null, }); const customerPatch = createBuilder({ data: null, error: null }); const client = createClient({ - billing_customers: [customerSelect, customerPatch], + billing_customers: [customerSelect, customerSelectAgain, customerPatch], }); const stripeService = createStripeMock(); const service = createService(client, stripeService); - await expect(service.createBaseCheckout(user)).resolves.toEqual({ + await expect(service.createDeviceCheckout(user, 3)).resolves.toEqual({ checkoutUrl: 'https://checkout.stripe.com/session', }); @@ -405,9 +569,276 @@ describe('PaymentsService', () => { ); expect(stripeService.createCheckout).toHaveBeenCalledWith( expect.objectContaining({ - priceId: BASE_PRICE, + priceId: DEVICE_PRICE, customerId: 'cus_new', userId: 'user-1', + quantity: 3, + adjustableQuantity: { minimum: 3 }, + }), + ); + }); + + it('createReportingCheckout rejects when the add-on is already active', async () => { + const customerSelect = createBuilder({ + data: stripeCustomer(), + error: null, + }); + const customerSelectAgain = createBuilder({ + data: stripeCustomer(), + error: null, + }); + const client = createClient({ + billing_customers: [customerSelect, customerSelectAgain], + }); + const stripeService = createStripeMock({ + listSubscriptions: jest.fn(() => Promise.resolve([reportingSub()])), + }); + const service = createService(client, stripeService); + + await expect( + service.createReportingCheckout(user), + ).rejects.toBeInstanceOf(ConflictException); + expect(stripeService.createCheckout).not.toHaveBeenCalled(); + }); + + it('cancelLicense refuses to drop below the seat minimum', async () => { + const licenseLoad = createBuilder({ + data: seatRow(13, 2), + error: null, + }); + const customerSelect = createBuilder({ + data: stripeCustomer(), + error: null, + }); + const licenseList = createBuilder({ + data: [seatRow(11, 0), seatRow(12, 1), seatRow(13, 2)], + error: null, + }); + const client = createClient({ + billing_customers: [customerSelect], + device_licenses: [licenseLoad, licenseList], + }); + const stripeService = createStripeMock({ + listSubscriptions: jest.fn(() => Promise.resolve([deviceSub()])), + }); + const service = createService(client, stripeService); + + await expect(service.cancelLicense(user, 13)).rejects.toBeInstanceOf( + ConflictException, + ); + expect(stripeService.updateSeats).not.toHaveBeenCalled(); + }); + }); + + describe('manual-invoice mode', () => { + it('getState never calls Stripe and reports staff-granted seats + reporting', async () => { + const customerSelect = createBuilder({ + data: manualCustomer({ reporting_manual: true }), + error: null, + }); + const licenseList = createBuilder({ + data: [ + seatRow(1, 0, { + stripe_subscription_id: null, + status: 'assigned', + dev_eui: 'EUI-1', + }), + seatRow(2, 1, { stripe_subscription_id: null }), + ], + error: null, + }); + const client = createClient({ + billing_customers: [customerSelect], + device_licenses: [licenseList], + }); + const stripeService = createStripeMock(); + const service = createService(client, stripeService); + + const state = await service.getState(user); + + expect(stripeService.listSubscriptions).not.toHaveBeenCalled(); + expect(stripeService.resolvePriceIds).not.toHaveBeenCalled(); + expect(state.billingMode).toBe('manual'); + expect(state.device).toEqual( + expect.objectContaining({ + seats: 2, + assignedCount: 1, + availableCount: 1, + }), + ); + expect(state.reporting).toEqual( + expect.objectContaining({ entitled: true, manual: true }), + ); + expect(state.licenses.map((l) => l.manual)).toEqual([true, true]); + }); + + it('adminSetManualSeats grants unassigned rows with no subscription id', async () => { + const customerSelect = createBuilder({ + data: manualCustomer(), + error: null, + }); + const licenseList = createBuilder({ data: [], error: null }); + const licenseInsert = createBuilder({ data: null, error: null }); + const client = createClient({ + billing_customers: [customerSelect], + device_licenses: [licenseList, licenseInsert], + }); + const service = createService(client, createStripeMock()); + + await expect(service.adminSetManualSeats('user-1', 3)).resolves.toEqual({ + userId: 'user-1', + seats: 3, + }); + expect(licenseInsert.insert).toHaveBeenCalledWith([ + expect.objectContaining({ + seat_index: 0, + stripe_subscription_id: null, + }), + expect.objectContaining({ + seat_index: 1, + stripe_subscription_id: null, + }), + expect.objectContaining({ + seat_index: 2, + stripe_subscription_id: null, + }), + ]); + }); + + it('adminSetManualSeats removes unassigned rows highest seat first and refuses to drop assigned ones', async () => { + const rows = [ + seatRow(1, 0, { + stripe_subscription_id: null, + status: 'assigned', + dev_eui: 'EUI-1', + }), + seatRow(2, 1, { stripe_subscription_id: null }), + seatRow(3, 2, { stripe_subscription_id: null }), + ]; + const shrinkClient = createClient({ + billing_customers: [ + createBuilder({ data: manualCustomer(), error: null }), + ], + device_licenses: [ + createBuilder({ data: rows, error: null }), + createBuilder({ data: null, error: null }), + ], + }); + const shrink = createService(shrinkClient, createStripeMock()); + await shrink.adminSetManualSeats('user-1', 1); + const deleteBuilder = shrinkClient.from.mock.results[2] + .value as QueryBuilder; + expect(deleteBuilder.delete).toHaveBeenCalled(); + expect(deleteBuilder.in).toHaveBeenCalledWith('id', [3, 2]); + + const refuseClient = createClient({ + billing_customers: [ + createBuilder({ data: manualCustomer(), error: null }), + ], + device_licenses: [createBuilder({ data: rows, error: null })], + }); + const refuse = createService(refuseClient, createStripeMock()); + await expect( + refuse.adminSetManualSeats('user-1', 0), + ).rejects.toBeInstanceOf(ConflictException); + }); + + it('adminSetManualSeats rejects customers on Stripe billing', async () => { + const client = createClient({ + billing_customers: [ + createBuilder({ data: stripeCustomer(), error: null }), + ], + }); + const service = createService(client, createStripeMock()); + await expect( + service.adminSetManualSeats('user-1', 3), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); + + describe('hasReportingEntitlement', () => { + const entitlementRow = (overrides: Record = {}) => ({ + stripe_customer_id: 'cus_1', + billing_mode: 'stripe', + reporting_status: null, + reporting_manual: false, + ...overrides, + }); + + it('is always true for staff without touching the database', async () => { + const client = createClient({}); + const service = createService(client, createStripeMock()); + await expect(service.hasReportingEntitlement(staff)).resolves.toBe(true); + expect(client.from).not.toHaveBeenCalled(); + }); + + it('trusts the staff-granted flag and an active cached status without calling Stripe', async () => { + const stripeService = createStripeMock(); + const grantedClient = createClient({ + billing_customers: [ + createBuilder({ + data: entitlementRow({ + billing_mode: 'manual', + reporting_manual: true, + }), + error: null, + }), + ], + }); + await expect( + createService(grantedClient, stripeService).hasReportingEntitlement( + user, + ), + ).resolves.toBe(true); + + const cachedClient = createClient({ + billing_customers: [ + createBuilder({ + data: entitlementRow({ reporting_status: 'active' }), + error: null, + }), + ], + }); + await expect( + createService(cachedClient, stripeService).hasReportingEntitlement( + user, + ), + ).resolves.toBe(true); + expect(stripeService.listSubscriptions).not.toHaveBeenCalled(); + }); + + it('denies manual-invoice customers without the staff-granted flag', async () => { + const client = createClient({ + billing_customers: [ + createBuilder({ + data: entitlementRow({ billing_mode: 'manual' }), + error: null, + }), + ], + }); + await expect( + createService(client, createStripeMock()).hasReportingEntitlement(user), + ).resolves.toBe(false); + }); + + it('consults Stripe when the cache is not active and refreshes the cache', async () => { + const cachePatch = createBuilder({ data: null, error: null }); + const client = createClient({ + billing_customers: [ + createBuilder({ data: entitlementRow(), error: null }), + cachePatch, + ], + }); + const stripeService = createStripeMock({ + listSubscriptions: jest.fn(() => Promise.resolve([reportingSub()])), + }); + await expect( + createService(client, stripeService).hasReportingEntitlement(user), + ).resolves.toBe(true); + expect(cachePatch.update).toHaveBeenCalledWith( + expect.objectContaining({ + reporting_subscription_id: 'sub_reporting', + reporting_status: 'active', }), ); }); diff --git a/src/v1/payments/payments.service.ts b/src/v1/payments/payments.service.ts index 558b550..9407ef6 100644 --- a/src/v1/payments/payments.service.ts +++ b/src/v1/payments/payments.service.ts @@ -15,8 +15,12 @@ import { MANAGE_CEILING } from '../common/permission-levels'; import type { TableInsert, TableRow } from '../types/supabase'; import { StripeService, BillingSubscriptionInfo } from './stripe.service'; import { + AdminBillingCustomer, + BillingEntitlementsResponse, BillingLicense, + BillingMode, BillingProductsResponse, + SEAT_MINIMUM, SubscriptionStateResponse, } from './payments.types'; import type { AuthenticatedUser } from '../auth/authenticated-user'; @@ -25,7 +29,7 @@ type BillingCustomerRow = TableRow<'billing_customers'>; type DeviceLicenseRow = TableRow<'device_licenses'>; type LicenseSeatRow = Pick< DeviceLicenseRow, - 'id' | 'seat_index' | 'status' | 'dev_eui' + 'id' | 'seat_index' | 'status' | 'dev_eui' | 'stripe_subscription_id' >; /** Shape of a PostgREST response from the untyped Supabase client. */ @@ -33,6 +37,17 @@ type QueryResult = { data: T | null; error: PostgrestError | null }; const ACTIVE_SUBSCRIPTION_STATUSES = ['active', 'trialing', 'past_due']; +const MANUAL_MODE_MESSAGE = + 'This account is invoiced by CropWatch. Contact support to change your licenses.'; + +function toBillingMode(value: string | null | undefined): BillingMode { + return value === 'manual' ? 'manual' : 'stripe'; +} + +function isActiveStatus(status: string | null | undefined): boolean { + return !!status && ACTIVE_SUBSCRIPTION_STATUSES.includes(status); +} + @Injectable() export class PaymentsService { private readonly logger = new Logger(PaymentsService.name); @@ -47,15 +62,15 @@ export class PaymentsService { // --------------------------------------------------------------------------- async getProducts(): Promise { - const { basePriceId, devicePriceId } = + const { devicePriceId, reportingPriceId } = await this.stripeService.resolvePriceIds(); const products = await this.stripeService.listProducts([ - basePriceId, devicePriceId, + reportingPriceId, ]); return { - base: products.find((p) => p.id === basePriceId) ?? null, device: products.find((p) => p.id === devicePriceId) ?? null, + reporting: products.find((p) => p.id === reportingPriceId) ?? null, }; } @@ -65,13 +80,43 @@ export class PaymentsService { const customer = await this.ensureBillingCustomer(client, userId); - const { basePriceId, devicePriceId } = + // Manual-invoice customers: everything is staff-granted, nothing in Stripe. + if (toBillingMode(customer.billing_mode) === 'manual') { + const licenses = await this.fetchLicenses(client, userId); + const assignedCount = licenses.filter( + (l) => l.status === 'assigned' && l.devEui, + ).length; + return { + billingMode: 'manual', + device: { + subscriptionId: null, + status: licenses.length > 0 ? 'active' : null, + seats: licenses.length, + minimumSeats: SEAT_MINIMUM, + assignedCount, + availableCount: Math.max(0, licenses.length - assignedCount), + currentPeriodEnd: null, + cancelAtPeriodEnd: false, + }, + reporting: { + subscriptionId: null, + status: customer.reporting_manual ? 'active' : null, + currentPeriodEnd: null, + cancelAtPeriodEnd: false, + entitled: customer.reporting_manual, + manual: true, + }, + licenses, + }; + } + + const { devicePriceId, reportingPriceId } = await this.stripeService.resolvePriceIds(); const subscriptions = await this.listSubscriptionsSafe( customer.stripe_customer_id, ); - const baseSub = this.pickSubscription(subscriptions, basePriceId); const deviceSub = this.pickSubscription(subscriptions, devicePriceId); + const reportingSub = this.pickSubscription(subscriptions, reportingPriceId); // Keep the local license rows in sync with the paid seat count. The webhook // is the primary driver, but reconciling here makes the page self-healing @@ -81,27 +126,40 @@ export class PaymentsService { await this.reconcileSeats(client, userId, deviceSub.id, targetSeats); } - await this.patchBillingCustomerCache(client, userId, baseSub, deviceSub); + await this.patchBillingCustomerCache( + client, + userId, + deviceSub, + reportingSub, + ); const licenses = await this.fetchLicenses(client, userId); const assignedCount = licenses.filter( (l) => l.status === 'assigned' && l.devEui, ).length; const seats = deviceSub ? this.effectiveSeats(deviceSub) : 0; + const reportingActive = + !!reportingSub && isActiveStatus(reportingSub.status); return { - base: { - subscriptionId: baseSub?.id ?? null, - status: baseSub?.status ?? null, - discountId: baseSub?.discountId ?? null, - currentPeriodEnd: baseSub?.currentPeriodEnd ?? null, - cancelAtPeriodEnd: baseSub?.cancelAtPeriodEnd ?? false, - }, + billingMode: 'stripe', device: { subscriptionId: deviceSub?.id ?? null, + status: deviceSub?.status ?? null, seats, + minimumSeats: SEAT_MINIMUM, assignedCount, availableCount: Math.max(0, seats - assignedCount), + currentPeriodEnd: deviceSub?.currentPeriodEnd ?? null, + cancelAtPeriodEnd: deviceSub?.cancelAtPeriodEnd ?? false, + }, + reporting: { + subscriptionId: reportingSub?.id ?? null, + status: reportingSub?.status ?? null, + currentPeriodEnd: reportingSub?.currentPeriodEnd ?? null, + cancelAtPeriodEnd: reportingSub?.cancelAtPeriodEnd ?? false, + entitled: reportingActive || customer.reporting_manual, + manual: !reportingActive && customer.reporting_manual, }, licenses, }; @@ -114,100 +172,181 @@ export class PaymentsService { } /** - * Whether the user has an active (or trialing / past-due) base subscription. - * Stripe is the source of truth; if Stripe is unreachable we fall back to the - * cached `billing_customers.base_status` so a transient outage doesn't block - * a legitimately-subscribed user. + * DB-only entitlement summary for pages that only need to know what the + * user may do. Never calls Stripe — the cached reporting status is kept + * current by the webhook and by getState(). */ - async hasActiveBaseSubscription(user: AuthenticatedUser): Promise { + async getEntitlements( + user: AuthenticatedUser, + ): Promise { const userId = user.sub; const client = this.supabaseService.getClient(); const { data: row } = (await client .from('billing_customers') - .select('stripe_customer_id, base_status') + .select('billing_mode, reporting_status, reporting_manual') .eq('user_id', userId) .maybeSingle()) as QueryResult< - Pick + Pick< + BillingCustomerRow, + 'billing_mode' | 'reporting_status' | 'reporting_manual' + > >; - if (!row?.stripe_customer_id) { + + const { count } = await client + .from('device_licenses') + .select('id', { count: 'exact', head: true }) + .eq('user_id', userId); + + const billingMode = toBillingMode(row?.billing_mode); + const reporting = + user.isStaff || + !!row?.reporting_manual || + (billingMode === 'stripe' && isActiveStatus(row?.reporting_status)); + + return { + billingMode, + isStaff: user.isStaff, + seats: count ?? 0, + reporting, + }; + } + + /** + * Whether the user may create / edit / regenerate reports. Staff always + * may; a staff-granted flag always grants; otherwise the Stripe reporting + * add-on must be active. The cached status is trusted when active (the + * webhook clears it when the add-on lapses); when it is not, Stripe is + * consulted once and the cache refreshed. Stripe outages fall back to the + * cache so a transient error never blocks a legitimately-subscribed user. + */ + async hasReportingEntitlement(user: AuthenticatedUser): Promise { + if (user.isStaff) { + return true; + } + const userId = user.sub; + const client = this.supabaseService.getClient(); + + const { data: row } = (await client + .from('billing_customers') + .select( + 'stripe_customer_id, billing_mode, reporting_status, reporting_manual', + ) + .eq('user_id', userId) + .maybeSingle()) as QueryResult< + Pick< + BillingCustomerRow, + | 'stripe_customer_id' + | 'billing_mode' + | 'reporting_status' + | 'reporting_manual' + > + >; + if (!row) { + return false; + } + if (row.reporting_manual) { + return true; + } + if (toBillingMode(row.billing_mode) === 'manual') { + return false; + } + if (isActiveStatus(row.reporting_status)) { + return true; + } + if (!row.stripe_customer_id) { return false; } try { - const { basePriceId } = await this.stripeService.resolvePriceIds(); - if (!basePriceId) { - throw new Error('Stripe base price id could not be resolved'); + const { reportingPriceId } = await this.stripeService.resolvePriceIds(); + if (!reportingPriceId) { + throw new Error('Stripe reporting price id could not be resolved'); } const subscriptions = await this.stripeService.listSubscriptions( row.stripe_customer_id, ); - const baseSub = this.pickSubscription(subscriptions, basePriceId); - return !!baseSub && ACTIVE_SUBSCRIPTION_STATUSES.includes(baseSub.status); + const reportingSub = this.pickSubscription( + subscriptions, + reportingPriceId, + ); + await this.patchBillingCustomer(client, userId, { + reporting_subscription_id: reportingSub?.id ?? null, + reporting_status: reportingSub?.status ?? null, + }); + return !!reportingSub && isActiveStatus(reportingSub.status); } catch (error) { this.logger.warn( - `Base-subscription check fell back to cache for ${userId}: ${String(error)}`, - ); - return ( - !!row.base_status && - ACTIVE_SUBSCRIPTION_STATUSES.includes(row.base_status) + `Reporting entitlement check fell back to cache for ${userId}: ${String(error)}`, ); + return isActiveStatus(row.reporting_status); } } // --------------------------------------------------------------------------- - // Checkout / portal / cancel + // Checkout / seats / portal / cancel // --------------------------------------------------------------------------- - async createBaseCheckout( + async createDeviceCheckout( user: AuthenticatedUser, - discountId?: string | null, + quantity: number, ): Promise<{ checkoutUrl: string }> { const userId = user.sub; const client = this.supabaseService.getClient(); + + const customer = await this.ensureBillingCustomer(client, userId); + this.assertStripeMode(customer); + if (quantity < SEAT_MINIMUM) { + throw new BadRequestException( + `Device subscriptions have a minimum of ${SEAT_MINIMUM} licenses.`, + ); + } const customerId = await this.ensureStripeCustomer(client, user); - const { basePriceId } = await this.stripeService.resolvePriceIds(); + const { devicePriceId } = await this.stripeService.resolvePriceIds(); const subscriptions = await this.listSubscriptionsSafe(customerId); - const existing = this.pickSubscription(subscriptions, basePriceId); - if (existing && ACTIVE_SUBSCRIPTION_STATUSES.includes(existing.status)) { - throw new ConflictException('A base subscription is already active.'); + const existing = this.pickSubscription(subscriptions, devicePriceId); + if (existing && isActiveStatus(existing.status)) { + throw new ConflictException( + 'A device subscription already exists. Change the seat count instead.', + ); } const checkoutUrl = await this.stripeService.createCheckout({ - priceId: this.requirePriceId(basePriceId, 'base'), + priceId: this.requirePriceId(devicePriceId, 'device'), customerId, userId, - promotionCodeId: discountId ?? null, + quantity, + // Let the customer adjust the seat count on the hosted checkout page + // (never below the minimum); the final quantity is confirmed by the + // webhook / getState reconcile. + adjustableQuantity: { minimum: SEAT_MINIMUM }, }); return { checkoutUrl }; } - async createDeviceCheckout( + async createReportingCheckout( user: AuthenticatedUser, - quantity: number, ): Promise<{ checkoutUrl: string }> { const userId = user.sub; const client = this.supabaseService.getClient(); + + const customer = await this.ensureBillingCustomer(client, userId); + this.assertStripeMode(customer); const customerId = await this.ensureStripeCustomer(client, user); - const { devicePriceId } = await this.stripeService.resolvePriceIds(); + const { reportingPriceId } = await this.stripeService.resolvePriceIds(); const subscriptions = await this.listSubscriptionsSafe(customerId); - const existing = this.pickSubscription(subscriptions, devicePriceId); - if (existing && ACTIVE_SUBSCRIPTION_STATUSES.includes(existing.status)) { - throw new ConflictException( - 'A device subscription already exists. Change the seat count instead.', - ); + const existing = this.pickSubscription(subscriptions, reportingPriceId); + if (existing && isActiveStatus(existing.status)) { + throw new ConflictException('The reporting package is already active.'); } const checkoutUrl = await this.stripeService.createCheckout({ - priceId: this.requirePriceId(devicePriceId, 'device'), + priceId: this.requirePriceId(reportingPriceId, 'reporting'), customerId, userId, - quantity, - // Let the customer adjust the seat count on the hosted checkout page; - // the final quantity is confirmed by webhook / getState reconcile. - adjustableQuantity: true, + quantity: 1, }); return { checkoutUrl }; } @@ -220,6 +359,13 @@ export class PaymentsService { const client = this.supabaseService.getClient(); const customer = await this.ensureBillingCustomer(client, userId); + this.assertStripeMode(customer); + if (seats < SEAT_MINIMUM) { + throw new BadRequestException( + `Device subscriptions have a minimum of ${SEAT_MINIMUM} licenses. Cancel the device subscription to go lower.`, + ); + } + const { devicePriceId } = await this.stripeService.resolvePriceIds(); const subscriptions = await this.listSubscriptionsSafe( customer.stripe_customer_id, @@ -277,7 +423,12 @@ export class PaymentsService { } } - async cancelBaseSubscription( + /** + * Cancel the whole device subscription. Immediate cancellation tears the + * license rows down right away; a period-end cancellation leaves them in + * place until the subscription.deleted webhook arrives. + */ + async cancelDeviceSubscription( user: AuthenticatedUser, atPeriodEnd: boolean, ): Promise<{ status: string }> { @@ -285,33 +436,57 @@ export class PaymentsService { const client = this.supabaseService.getClient(); const customer = await this.ensureBillingCustomer(client, userId); - const { basePriceId, devicePriceId } = - await this.stripeService.resolvePriceIds(); + this.assertStripeMode(customer); + const { devicePriceId } = await this.stripeService.resolvePriceIds(); const subscriptions = await this.listSubscriptionsSafe( customer.stripe_customer_id, ); - const baseSub = this.pickSubscription(subscriptions, basePriceId); - if (!baseSub) { - throw new NotFoundException('No base subscription to cancel.'); + const deviceSub = this.pickSubscription(subscriptions, devicePriceId); + if (!deviceSub) { + throw new NotFoundException('No device subscription to cancel.'); } - const updated = await this.stripeService.cancelSubscription( - baseSub.id, - atPeriodEnd, - ); + await this.stripeService.cancelSubscription(deviceSub.id, atPeriodEnd); - // The device subscription (all device licenses) cannot exist without the - // base subscription, so cancel it with the same timing. The license rows are - // torn down by the webhook when the subscription actually ends (immediately, - // or at period end) — see applySubscriptionState. - const deviceSub = this.pickSubscription(subscriptions, devicePriceId); - if (deviceSub) { - await this.stripeService.cancelSubscription(deviceSub.id, atPeriodEnd); + if (!atPeriodEnd) { + await this.deleteStripeLicenses(client, userId); + await this.patchBillingCustomer(client, userId, { + device_subscription_id: null, + device_seats: 0, + }); } + return { status: atPeriodEnd ? 'canceling' : 'canceled' }; + } + + async cancelReportingSubscription( + user: AuthenticatedUser, + atPeriodEnd: boolean, + ): Promise<{ status: string }> { + const userId = user.sub; + const client = this.supabaseService.getClient(); + const customer = await this.ensureBillingCustomer(client, userId); + this.assertStripeMode(customer); + const { reportingPriceId } = await this.stripeService.resolvePriceIds(); + const subscriptions = await this.listSubscriptionsSafe( + customer.stripe_customer_id, + ); + const reportingSub = this.pickSubscription(subscriptions, reportingPriceId); + if (!reportingSub) { + throw new NotFoundException('No reporting subscription to cancel.'); + } + + const updated = await this.stripeService.cancelSubscription( + reportingSub.id, + atPeriodEnd, + ); await this.patchBillingCustomer(client, userId, { - base_status: updated.status, + reporting_subscription_id: atPeriodEnd ? reportingSub.id : null, + reporting_status: updated.status, }); + if (!atPeriodEnd) { + await this.deactivateReportTemplates(client, userId); + } return { status: atPeriodEnd ? 'canceling' : 'canceled' }; } @@ -402,9 +577,9 @@ export class PaymentsService { } /** - * Cancel a single UNASSIGNED license: drops the paid seat count by one (or - * cancels the device subscription outright when it's the last seat, since - * the seat minimum is 1). Assigned licenses must be unassigned first. + * Cancel a single UNASSIGNED Stripe-backed license: drops the paid seat + * count by one. Never goes below the seat minimum — cancel the device + * subscription for that. Assigned licenses must be unassigned first. */ async cancelLicense( user: AuthenticatedUser, @@ -414,6 +589,9 @@ export class PaymentsService { const client = this.supabaseService.getClient(); const license = await this.loadOwnedLicense(client, userId, licenseId); + if (license.stripe_subscription_id === null) { + throw new BadRequestException(MANUAL_MODE_MESSAGE); + } if (license.dev_eui || license.status === 'assigned') { throw new ConflictException( 'Only unassigned licenses can be canceled. Unassign it from its device first.', @@ -430,18 +608,18 @@ export class PaymentsService { throw new BadRequestException('No device subscription found.'); } - const target = (await this.fetchLicenses(client, userId)).length - 1; - if (target >= 1) { - await this.stripeService.updateSeats(deviceSub.id, target); - } else { - // Last seat: cancel the device subscription instead of going to 0 seats. - await this.stripeService.cancelSubscription(deviceSub.id, false); - await this.patchBillingCustomer(client, userId, { - device_subscription_id: null, - device_seats: 0, - }); + const stripeSeats = (await this.fetchLicenses(client, userId)).filter( + (l) => !l.manual, + ).length; + const target = stripeSeats - 1; + if (target < SEAT_MINIMUM) { + throw new ConflictException( + `Device subscriptions have a minimum of ${SEAT_MINIMUM} licenses. Cancel the device subscription instead.`, + ); } + await this.stripeService.updateSeats(deviceSub.id, target); + // Remove this specific seat now; the resulting webhook reconciles to match. const { error } = await client .from('device_licenses') @@ -456,6 +634,231 @@ export class PaymentsService { return { canceled: true }; } + // --------------------------------------------------------------------------- + // Staff administration + // --------------------------------------------------------------------------- + + /** + * Every device owner and every billing customer, with device / license / + * subscription counts — the staff overview used to spot legacy unlicensed + * devices and to manage manual-invoice customers. + */ + async adminListCustomers(): Promise { + const client = this.supabaseService.getAdminClient(); + + const [profiles, devices, ownerRows, licenses, customers] = + await Promise.all([ + this.readAll, 'id' | 'email' | 'full_name'>>( + client, + 'profiles', + 'id, email, full_name', + ), + this.readAll, 'dev_eui' | 'user_id'>>( + client, + 'cw_devices', + 'dev_eui, user_id', + ), + this.readAll< + Pick< + TableRow<'cw_device_owners'>, + 'dev_eui' | 'user_id' | 'permission_level' + > + >(client, 'cw_device_owners', 'dev_eui, user_id, permission_level'), + this.readAll< + Pick< + DeviceLicenseRow, + 'user_id' | 'dev_eui' | 'stripe_subscription_id' + > + >( + client, + 'device_licenses', + 'user_id, dev_eui, stripe_subscription_id', + ), + this.readAll(client, 'billing_customers', '*'), + ]); + + // Device owner = cw_devices.user_id, else the first admin-level owner row. + const adminOwnerByDevice = new Map(); + for (const row of ownerRows) { + if ( + Number(row.permission_level) === 1 && + !adminOwnerByDevice.has(row.dev_eui) + ) { + adminOwnerByDevice.set(row.dev_eui, row.user_id); + } + } + const devicesByOwner = new Map(); + for (const device of devices) { + const owner = device.user_id ?? adminOwnerByDevice.get(device.dev_eui); + if (!owner) { + continue; + } + const list = devicesByOwner.get(owner) ?? []; + list.push(device.dev_eui); + devicesByOwner.set(owner, list); + } + + const licensedDevices = new Set( + licenses.map((l) => l.dev_eui).filter((d): d is string => !!d), + ); + const licensesByUser = new Map(); + for (const license of licenses) { + const list = licensesByUser.get(license.user_id) ?? []; + list.push(license); + licensesByUser.set(license.user_id, list); + } + const customerByUser = new Map(customers.map((c) => [c.user_id, c])); + const profileByUser = new Map(profiles.map((p) => [p.id, p])); + + const userIds = new Set([ + ...devicesByOwner.keys(), + ...customerByUser.keys(), + ]); + + const rows: AdminBillingCustomer[] = []; + for (const userId of userIds) { + const profile = profileByUser.get(userId); + const customer = customerByUser.get(userId); + const owned = devicesByOwner.get(userId) ?? []; + const userLicenses = licensesByUser.get(userId) ?? []; + rows.push({ + userId, + email: profile?.email ?? null, + fullName: profile?.full_name ?? null, + billingMode: toBillingMode(customer?.billing_mode), + deviceCount: owned.length, + licensedDeviceCount: owned.filter((d) => licensedDevices.has(d)).length, + seatCount: userLicenses.length, + manualSeatCount: userLicenses.filter( + (l) => l.stripe_subscription_id === null, + ).length, + stripeCustomerId: customer?.stripe_customer_id ?? null, + deviceSubscriptionId: customer?.device_subscription_id ?? null, + deviceSeats: customer?.device_seats ?? 0, + reportingStatus: customer?.reporting_status ?? null, + reportingManual: customer?.reporting_manual ?? false, + }); + } + + return rows.sort((a, b) => + (a.email ?? '').localeCompare(b.email ?? '', undefined, { + sensitivity: 'base', + }), + ); + } + + async adminSetBillingMode( + userId: string, + billingMode: BillingMode, + ): Promise<{ userId: string; billingMode: BillingMode }> { + const client = this.supabaseService.getAdminClient(); + const customer = await this.ensureBillingCustomer(client, userId); + if (toBillingMode(customer.billing_mode) === billingMode) { + return { userId, billingMode }; + } + + if (billingMode === 'stripe') { + // Staff-granted seats cannot coexist with a Stripe device subscription. + const { count } = await client + .from('device_licenses') + .select('id', { count: 'exact', head: true }) + .eq('user_id', userId) + .is('stripe_subscription_id', null); + if ((count ?? 0) > 0) { + throw new ConflictException( + 'Revoke the staff-granted licenses before switching this customer to Stripe billing.', + ); + } + } else if ( + customer.device_subscription_id || + isActiveStatus(customer.reporting_status) + ) { + throw new ConflictException( + 'Cancel the Stripe subscriptions before switching this customer to manual invoicing.', + ); + } + + await this.patchBillingCustomer(client, userId, { + billing_mode: billingMode, + }); + return { userId, billingMode }; + } + + /** + * Set the number of staff-granted seats for a manual-invoice customer + * (absolute target). Adds unassigned rows or removes unassigned ones — + * assigned staff-granted seats are never removed here. + */ + async adminSetManualSeats( + userId: string, + seats: number, + ): Promise<{ userId: string; seats: number }> { + const client = this.supabaseService.getAdminClient(); + const customer = await this.ensureBillingCustomer(client, userId); + if (toBillingMode(customer.billing_mode) !== 'manual') { + throw new BadRequestException( + 'Staff-granted licenses require the customer to be on manual invoicing.', + ); + } + + const rows = await this.readSeatRows(client, userId); + const manualRows = rows.filter((r) => r.stripe_subscription_id === null); + const current = manualRows.length; + + if (seats > current) { + const startIndex = + rows.length > 0 ? Math.max(...rows.map((r) => r.seat_index)) + 1 : 0; + const inserts: TableInsert<'device_licenses'>[] = []; + for (let i = 0; i < seats - current; i += 1) { + inserts.push({ + user_id: userId, + stripe_subscription_id: null, + seat_index: startIndex + i, + dev_eui: null, + status: 'unassigned', + }); + } + const { error } = await client.from('device_licenses').insert(inserts); + if (error) { + throw new InternalServerErrorException('Failed to grant licenses'); + } + } else if (seats < current) { + const removable = manualRows + .filter((r) => r.status !== 'assigned' && !r.dev_eui) + .sort((a, b) => b.seat_index - a.seat_index); + const need = current - seats; + if (removable.length < need) { + throw new ConflictException( + `Cannot reduce to ${seats} licenses: ${current - removable.length} staff-granted licenses are assigned to devices. Unassign them first.`, + ); + } + const { error } = await client + .from('device_licenses') + .delete() + .in( + 'id', + removable.slice(0, need).map((r) => r.id), + ); + if (error) { + throw new InternalServerErrorException('Failed to revoke licenses'); + } + } + + return { userId, seats }; + } + + async adminSetReportingManual( + userId: string, + manual: boolean, + ): Promise<{ userId: string; reportingManual: boolean }> { + const client = this.supabaseService.getAdminClient(); + await this.ensureBillingCustomer(client, userId); + await this.patchBillingCustomer(client, userId, { + reporting_manual: manual, + }); + return { userId, reportingManual: manual }; + } + // --------------------------------------------------------------------------- // Webhook // --------------------------------------------------------------------------- @@ -582,22 +985,21 @@ export class PaymentsService { ): Promise { await this.linkCustomer(client, userId, customerId); - const { basePriceId, devicePriceId } = + const { devicePriceId, reportingPriceId } = await this.stripeService.resolvePriceIds(); - if (basePriceId && subscription.priceId === basePriceId) { - if (isDeleted) { + if (reportingPriceId && subscription.priceId === reportingPriceId) { + if (isDeleted || subscription.status === 'canceled') { await this.patchBillingCustomer(client, userId, { - base_subscription_id: null, - base_status: 'canceled', - base_discount_id: null, + reporting_subscription_id: null, + reporting_status: 'canceled', }); + await this.deactivateReportTemplates(client, userId); return; } await this.patchBillingCustomer(client, userId, { - base_subscription_id: subscription.id, - base_status: subscription.status, - base_discount_id: subscription.discountId, + reporting_subscription_id: subscription.id, + reporting_status: subscription.status, }); return; } @@ -605,11 +1007,11 @@ export class PaymentsService { if (devicePriceId && subscription.priceId === devicePriceId) { // Deleted (or status 'canceled') = access has actually ended — either an // immediate cancel or a scheduled cancel reaching period end. Tear down - // EVERY license, assigned or not. A still-scheduled cancel + // EVERY Stripe-backed license, assigned or not. A still-scheduled cancel // (cancel_at_period_end=true while status stays 'active') keeps the seats // live, so we fall through and reconcile to the current paid seat count. if (isDeleted || subscription.status === 'canceled') { - await this.deleteAllLicenses(client, userId); + await this.deleteStripeLicenses(client, userId); await this.patchBillingCustomer(client, userId, { device_subscription_id: null, device_seats: 0, @@ -680,10 +1082,12 @@ export class PaymentsService { } // --------------------------------------------------------------------------- - // Seat reconciliation — converge license rows to the paid seat count. - // Idempotent: only ever inserts unassigned rows or deletes unassigned rows. - // Assigned rows are never destroyed here (the API blocks decreases below the - // assigned count); an unsatisfiable decrease is logged as an overage. + // Seat reconciliation — converge Stripe-backed license rows to the paid + // seat count. Idempotent: only ever inserts unassigned rows or deletes + // unassigned rows. Assigned rows are never destroyed here (the API blocks + // decreases below the assigned count); an unsatisfiable decrease is logged + // as an overage. Staff-granted rows (NULL subscription id) are ignored + // entirely, apart from reserving their seat_index values. // --------------------------------------------------------------------------- private async reconcileSeats( @@ -691,20 +1095,11 @@ export class PaymentsService { userId: string, subscriptionId: string, targetSeats: number, + retried = false, ): Promise { - const { data, error } = (await client - .from('device_licenses') - .select('id, seat_index, status, dev_eui') - .eq('user_id', userId) - .order('seat_index', { ascending: true })) as QueryResult< - LicenseSeatRow[] - >; - if (error) { - throw new InternalServerErrorException('Failed to read device licenses'); - } - - const rows = data ?? []; - const current = rows.length; + const rows = await this.readSeatRows(client, userId); + const stripeRows = rows.filter((r) => r.stripe_subscription_id !== null); + const current = stripeRows.length; if (targetSeats > current) { const startIndex = @@ -723,13 +1118,26 @@ export class PaymentsService { .from('device_licenses') .insert(inserts); if (insertError) { + // checkout.session.completed and customer.subscription.created arrive + // near-simultaneously and both try to add the same seats; the loser + // hits the (user_id, seat_index) unique constraint. Re-read once — the + // winner's rows now exist, so this converges to a no-op. + if (insertError.code === '23505' && !retried) { + return this.reconcileSeats( + client, + userId, + subscriptionId, + targetSeats, + true, + ); + } throw new InternalServerErrorException('Failed to add device licenses'); } return; } if (targetSeats < current) { - const removable = rows + const removable = stripeRows .filter((r) => r.status !== 'assigned' && !r.dev_eui) .sort((a, b) => b.seat_index - a.seat_index); const toRemove = removable @@ -760,6 +1168,56 @@ export class PaymentsService { // Helpers // --------------------------------------------------------------------------- + private assertStripeMode(customer: BillingCustomerRow): void { + if (toBillingMode(customer.billing_mode) === 'manual') { + throw new BadRequestException(MANUAL_MODE_MESSAGE); + } + } + + private async readSeatRows( + client: SupabaseClient, + userId: string, + ): Promise { + const { data, error } = (await client + .from('device_licenses') + .select('id, seat_index, status, dev_eui, stripe_subscription_id') + .eq('user_id', userId) + .order('seat_index', { ascending: true })) as QueryResult< + LicenseSeatRow[] + >; + if (error) { + throw new InternalServerErrorException('Failed to read device licenses'); + } + return data ?? []; + } + + /** + * Read an entire table in pages. PostgREST caps a single response at 1000 + * rows by default and `cw_device_owners` is already past that. + */ + private async readAll( + client: SupabaseClient, + table: string, + columns: string, + ): Promise { + const pageSize = 1000; + const rows: T[] = []; + for (let from = 0; ; from += pageSize) { + const { data, error } = (await client + .from(table) + .select(columns) + .range(from, from + pageSize - 1)) as unknown as QueryResult; + if (error) { + throw new InternalServerErrorException(`Failed to read ${table}`); + } + const page = data ?? []; + rows.push(...page); + if (page.length < pageSize) { + return rows; + } + } + } + private async ensureBillingCustomer( client: SupabaseClient, userId: string, @@ -859,18 +1317,37 @@ export class PaymentsService { private async patchBillingCustomerCache( client: SupabaseClient, userId: string, - baseSub: BillingSubscriptionInfo | null, deviceSub: BillingSubscriptionInfo | null, + reportingSub: BillingSubscriptionInfo | null, ): Promise { await this.patchBillingCustomer(client, userId, { - base_subscription_id: baseSub?.id ?? null, - base_status: baseSub?.status ?? null, - base_discount_id: baseSub?.discountId ?? null, device_subscription_id: deviceSub?.id ?? null, device_seats: deviceSub ? this.effectiveSeats(deviceSub) : 0, + reporting_subscription_id: reportingSub?.id ?? null, + reporting_status: reportingSub?.status ?? null, }); } + /** + * Best-effort: stop the CW-Reports cron from generating reports for a user + * whose reporting add-on has ended. The user can re-enable templates after + * re-subscribing (update() is entitlement-gated). + */ + private async deactivateReportTemplates( + client: SupabaseClient, + userId: string, + ): Promise { + const { error } = await client + .from('cw_report_templates') + .update({ is_active: false }) + .eq('created_by', userId); + if (error) { + this.logger.warn( + `Failed to deactivate report templates for ${userId}: ${error.message}`, + ); + } + } + private async listSubscriptionsSafe( stripeCustomerId: string | null, ): Promise { @@ -1024,15 +1501,19 @@ export class PaymentsService { return this.fetchLicense(client, userId, licenseId); } - /** Remove every license row for a user (used when the device sub ends). */ - private async deleteAllLicenses( + /** + * Remove every Stripe-backed license row for a user (used when the device + * subscription ends). Staff-granted rows are left alone. + */ + private async deleteStripeLicenses( client: SupabaseClient, userId: string, ): Promise { const { error } = await client .from('device_licenses') .delete() - .eq('user_id', userId); + .eq('user_id', userId) + .not('stripe_subscription_id', 'is', null); if (error) { this.logger.warn( `Failed to delete device licenses for ${userId}: ${error.message}`, @@ -1046,7 +1527,9 @@ export class PaymentsService { ): Promise { const { data, error } = await client .from('device_licenses') - .select('id, seat_index, status, dev_eui, cw_devices(name)') + .select( + 'id, seat_index, status, dev_eui, stripe_subscription_id, cw_devices(name)', + ) .eq('user_id', userId) .order('seat_index', { ascending: true }); if (error) { @@ -1062,7 +1545,9 @@ export class PaymentsService { ): Promise { const { data, error } = await client .from('device_licenses') - .select('id, seat_index, status, dev_eui, cw_devices(name)') + .select( + 'id, seat_index, status, dev_eui, stripe_subscription_id, cw_devices(name)', + ) .eq('id', licenseId) .eq('user_id', userId) .single(); @@ -1077,6 +1562,7 @@ export class PaymentsService { seat_index: number; status: string; dev_eui: string | null; + stripe_subscription_id?: string | null; cw_devices?: { name: string | null } | { name: string | null }[] | null; }): BillingLicense { const device = Array.isArray(row.cw_devices) @@ -1088,6 +1574,7 @@ export class PaymentsService { status: row.status, devEui: row.dev_eui, deviceName: device?.name ?? null, + manual: (row.stripe_subscription_id ?? null) === null, }; } } diff --git a/src/v1/payments/payments.types.ts b/src/v1/payments/payments.types.ts index 0feafd2..fcc2519 100644 --- a/src/v1/payments/payments.types.ts +++ b/src/v1/payments/payments.types.ts @@ -1,37 +1,94 @@ import { BillingProductInfo } from './stripe.service'; -/** A single device license (one paid seat) and its current device assignment. */ +/** + * A device subscription must always carry at least this many seats. Enforced + * on the hosted checkout (adjustable_quantity.minimum), on seat changes, and + * on per-seat cancellation; going lower means canceling the subscription. + */ +export const SEAT_MINIMUM = 3; + +/** + * How a customer pays. + * - `stripe`: self-serve subscriptions via Stripe Checkout (default). + * - `manual`: invoiced outside Stripe; seats and reporting are granted by + * CropWatch staff (device_licenses rows with a NULL subscription id). + */ +export type BillingMode = 'stripe' | 'manual'; + +/** A single device license (one seat) and its current device assignment. */ export interface BillingLicense { id: number; seatIndex: number; status: string; // 'assigned' | 'unassigned' devEui: string | null; deviceName: string | null; + /** True when the seat was granted by staff (not backed by a Stripe subscription). */ + manual: boolean; } -export interface BaseSubscriptionState { +export interface DeviceSubscriptionState { subscriptionId: string | null; status: string | null; // active | trialing | past_due | canceled | null - discountId: string | null; + seats: number; // paid (or staff-granted) licenses + minimumSeats: number; // SEAT_MINIMUM + assignedCount: number; // licenses currently attached to a device + availableCount: number; // seats - assignedCount currentPeriodEnd: string | null; cancelAtPeriodEnd: boolean; } -export interface DeviceSubscriptionState { +export interface ReportingSubscriptionState { subscriptionId: string | null; - seats: number; // paid licenses - assignedCount: number; // licenses currently attached to a device - availableCount: number; // seats - assignedCount + status: string | null; + currentPeriodEnd: string | null; + cancelAtPeriodEnd: boolean; + /** Whether the user may create/edit/regenerate reports right now. */ + entitled: boolean; + /** True when the entitlement was granted by staff rather than Stripe. */ + manual: boolean; } /** The full billing overview returned to the account/billing page. */ export interface SubscriptionStateResponse { - base: BaseSubscriptionState; + billingMode: BillingMode; device: DeviceSubscriptionState; + reporting: ReportingSubscriptionState; licenses: BillingLicense[]; } export interface BillingProductsResponse { - base: BillingProductInfo | null; device: BillingProductInfo | null; + reporting: BillingProductInfo | null; +} + +/** + * Cheap, DB-only entitlement summary for pages that just need to know what + * the user may do (e.g. the reports pages). Never calls Stripe. + */ +export interface BillingEntitlementsResponse { + billingMode: BillingMode; + isStaff: boolean; + seats: number; + reporting: boolean; +} + +/** One row of the staff billing overview (`GET /payments/admin/customers`). */ +export interface AdminBillingCustomer { + userId: string; + email: string | null; + fullName: string | null; + billingMode: BillingMode; + /** Devices this user owns (cw_devices.user_id, else their admin-level owner row). */ + deviceCount: number; + /** Of those devices, how many carry a license (from any user). */ + licensedDeviceCount: number; + /** Total license rows owned by this user. */ + seatCount: number; + /** License rows granted by staff (NULL subscription id). */ + manualSeatCount: number; + stripeCustomerId: string | null; + deviceSubscriptionId: string | null; + deviceSeats: number; + reportingStatus: string | null; + reportingManual: boolean; } diff --git a/src/v1/payments/stripe.service.spec.ts b/src/v1/payments/stripe.service.spec.ts index 8fdf182..b713b72 100644 --- a/src/v1/payments/stripe.service.spec.ts +++ b/src/v1/payments/stripe.service.spec.ts @@ -15,13 +15,13 @@ describe('StripeService', () => { it('resolvePriceIds prefers env overrides without calling Stripe', async () => { const service = createService({ STRIPE_SECRET_KEY: 'sk_test_dummy', - STRIPE_BASE_PRICE_ID: 'price_base_env', STRIPE_DEVICE_PRICE_ID: 'price_device_env', + STRIPE_REPORTING_PRICE_ID: 'price_reporting_env', }); await expect(service.resolvePriceIds()).resolves.toEqual({ - basePriceId: 'price_base_env', devicePriceId: 'price_device_env', + reportingPriceId: 'price_reporting_env', }); }); diff --git a/src/v1/payments/stripe.service.ts b/src/v1/payments/stripe.service.ts index d98c399..0e3eb0a 100644 --- a/src/v1/payments/stripe.service.ts +++ b/src/v1/payments/stripe.service.ts @@ -9,13 +9,13 @@ import Stripe from 'stripe'; * needed; transfer a lookup key to a new price in Stripe to change pricing * without touching code or config. */ -export const BASE_PRICE_LOOKUP_KEY = 'cropwatch_base_monthly'; export const DEVICE_PRICE_LOOKUP_KEY = 'cropwatch_device_seat_monthly'; +export const REPORTING_PRICE_LOOKUP_KEY = 'cropwatch_reporting_monthly'; /** The resolved Stripe price ids for the two subscription products. */ export interface BillingPriceIds { - basePriceId: string; devicePriceId: string; + reportingPriceId: string; } /** Plain price descriptor decoupled from the SDK's price type. */ @@ -80,50 +80,56 @@ export class StripeService { private priceIds: BillingPriceIds | null = null; /** - * Resolve the base/device price ids: env override first, else look them up - * by lookup key. Cached for the process lifetime once fully resolved. - * Never throws — unresolved ids come back as '' (callers treat that as - * "not configured"), so read paths keep degrading gracefully on an outage. + * Resolve the device/reporting price ids: env override first, else look + * them up by lookup key. Cached for the process lifetime once fully + * resolved. Never throws — unresolved ids come back as '' (callers treat + * that as "not configured"), so read paths keep degrading gracefully on an + * outage. */ async resolvePriceIds(): Promise { if (this.priceIds) { return this.priceIds; } - const envBase = this.configService.get('STRIPE_BASE_PRICE_ID'); const envDevice = this.configService.get('STRIPE_DEVICE_PRICE_ID'); - if (envBase && envDevice) { - this.priceIds = { basePriceId: envBase, devicePriceId: envDevice }; + const envReporting = this.configService.get( + 'STRIPE_REPORTING_PRICE_ID', + ); + if (envDevice && envReporting) { + this.priceIds = { + devicePriceId: envDevice, + reportingPriceId: envReporting, + }; return this.priceIds; } - let basePriceId = envBase ?? ''; let devicePriceId = envDevice ?? ''; + let reportingPriceId = envReporting ?? ''; try { const prices = await this.stripe.prices.list({ - lookup_keys: [BASE_PRICE_LOOKUP_KEY, DEVICE_PRICE_LOOKUP_KEY], + lookup_keys: [DEVICE_PRICE_LOOKUP_KEY, REPORTING_PRICE_LOOKUP_KEY], active: true, }); for (const price of prices.data) { - if (price.lookup_key === BASE_PRICE_LOOKUP_KEY) { - basePriceId ||= price.id; - } else if (price.lookup_key === DEVICE_PRICE_LOOKUP_KEY) { + if (price.lookup_key === DEVICE_PRICE_LOOKUP_KEY) { devicePriceId ||= price.id; + } else if (price.lookup_key === REPORTING_PRICE_LOOKUP_KEY) { + reportingPriceId ||= price.id; } } } catch (error) { this.logger.warn(`Failed to resolve Stripe price ids: ${String(error)}`); } - if (!basePriceId || !devicePriceId) { + if (!devicePriceId || !reportingPriceId) { this.logger.error( - `Stripe prices not found for lookup keys ${BASE_PRICE_LOOKUP_KEY} / ${DEVICE_PRICE_LOOKUP_KEY} — run scripts/stripe-bootstrap.mjs or set STRIPE_BASE_PRICE_ID / STRIPE_DEVICE_PRICE_ID`, + `Stripe prices not found for lookup keys ${DEVICE_PRICE_LOOKUP_KEY} / ${REPORTING_PRICE_LOOKUP_KEY} — run scripts/stripe-bootstrap.mjs or set STRIPE_DEVICE_PRICE_ID / STRIPE_REPORTING_PRICE_ID`, ); // Don't cache a partial result; retry on the next call. - return { basePriceId, devicePriceId }; + return { devicePriceId, reportingPriceId }; } - this.priceIds = { basePriceId, devicePriceId }; + this.priceIds = { devicePriceId, reportingPriceId }; return this.priceIds; } @@ -135,6 +141,18 @@ export class StripeService { return this.configService.get('STRIPE_CHECKOUT_SUCCESS_URL') ?? ''; } + /** + * Where Stripe sends the customer when they abandon a checkout. Separate + * from the portal return URL so the app can tell the two apart + * (`?checkout=cancel`); falls back to the billing return URL. + */ + private get checkoutCancelUrl(): string { + return ( + this.configService.get('STRIPE_CHECKOUT_CANCEL_URL') || + this.billingReturnUrl + ); + } + private get billingReturnUrl(): string { return this.configService.get('STRIPE_BILLING_RETURN_URL') ?? ''; } @@ -188,8 +206,8 @@ export class StripeService { customerId: string; userId: string; quantity?: number; - adjustableQuantity?: boolean; - promotionCodeId?: string | null; + /** Let the customer change the quantity on the hosted page, never below `minimum`. */ + adjustableQuantity?: { minimum: number }; }): Promise { const session = await this.stripe.checkout.sessions.create({ mode: 'subscription', @@ -200,16 +218,19 @@ export class StripeService { price: input.priceId, quantity: input.quantity ?? 1, ...(input.adjustableQuantity - ? { adjustable_quantity: { enabled: true, minimum: 1 } } + ? { + adjustable_quantity: { + enabled: true, + minimum: input.adjustableQuantity.minimum, + }, + } : {}), }, ], subscription_data: { metadata: { user_id: input.userId } }, - ...(input.promotionCodeId - ? { discounts: [{ promotion_code: input.promotionCodeId }] } - : { allow_promotion_codes: true }), + allow_promotion_codes: true, success_url: this.checkoutSuccessUrl || undefined, - cancel_url: this.billingReturnUrl || undefined, + cancel_url: this.checkoutCancelUrl || undefined, }); if (!session.url) { throw new Error('Stripe checkout session has no redirect URL'); diff --git a/src/v1/reports/reports.module.ts b/src/v1/reports/reports.module.ts index 4863089..6907fd7 100644 --- a/src/v1/reports/reports.module.ts +++ b/src/v1/reports/reports.module.ts @@ -2,11 +2,12 @@ import { Module } from '@nestjs/common'; import { SupabaseModule } from '../../supabase/supabase.module'; import { DevicesModule } from '../devices/devices.module'; import { LocationsModule } from '../locations/locations.module'; +import { PaymentsModule } from '../payments/payments.module'; import { ReportsController } from './reports.controller'; import { ReportsService } from './reports.service'; @Module({ - imports: [SupabaseModule, DevicesModule, LocationsModule], + imports: [SupabaseModule, DevicesModule, LocationsModule, PaymentsModule], controllers: [ReportsController], providers: [ReportsService], }) diff --git a/src/v1/reports/reports.service.spec.ts b/src/v1/reports/reports.service.spec.ts index caa47eb..8a774b3 100644 --- a/src/v1/reports/reports.service.spec.ts +++ b/src/v1/reports/reports.service.spec.ts @@ -4,6 +4,7 @@ import { RequestReportRegenerationDto } from './dto/request-report-regeneration. import { SupabaseService } from '../../supabase/supabase.service'; import { DevicesService } from '../devices/devices.service'; import { LocationsService } from '../locations/locations.service'; +import { PaymentsService } from '../payments/payments.service'; import * as managedDevicesHelper from '../common/managed-devices.helper'; jest.mock('../common/managed-devices.helper', () => ({ @@ -58,6 +59,7 @@ function createQueueTableMock() { describe('ReportsService.requestRegeneration', () => { let service: ReportsService; let queueTable: ReturnType; + let hasReportingEntitlement: jest.Mock; const listManagedDevices = managedDevicesHelper.listManagedDevices as jest.Mock; @@ -74,10 +76,12 @@ describe('ReportsService.requestRegeneration', () => { getClient: jest.fn(() => client), } as unknown as SupabaseService; + hasReportingEntitlement = jest.fn().mockResolvedValue(true); service = new ReportsService( supabaseService, {} as DevicesService, {} as LocationsService, + { hasReportingEntitlement } as unknown as PaymentsService, ); // findOne is exercised by its own integration paths; here it gates the @@ -92,6 +96,15 @@ describe('ReportsService.requestRegeneration', () => { ]); }); + it('requestRegeneration rejects with 403 when the user has no reporting entitlement', async () => { + hasReportingEntitlement.mockResolvedValue(false); + + await expect( + service.requestRegeneration(42, baseDto(), USER), + ).rejects.toBeInstanceOf(ForbiddenException); + expect(queueTable.insert).not.toHaveBeenCalled(); + }); + afterEach(() => { jest.clearAllMocks(); }); diff --git a/src/v1/reports/reports.service.ts b/src/v1/reports/reports.service.ts index ea5c101..1ffad06 100644 --- a/src/v1/reports/reports.service.ts +++ b/src/v1/reports/reports.service.ts @@ -20,6 +20,7 @@ import { } from '../common/collection.helpers'; import { DevicesService } from '../devices/devices.service'; import { LocationsService } from '../locations/locations.service'; +import { PaymentsService } from '../payments/payments.service'; import { CommunicationMethodDto } from './dto/communication-method.dto'; import { ReportFormContextDto } from './dto/report-form-context.dto'; import { ReportTemplateAlertPointDto } from './dto/report-template-alert-point.dto'; @@ -136,8 +137,24 @@ export class ReportsService { private readonly supabaseService: SupabaseService, private readonly devicesService: DevicesService, private readonly locationsService: LocationsService, + private readonly paymentsService: PaymentsService, ) {} + /** + * Creating, editing, and regenerating reports requires the reporting + * add-on (or a staff-granted entitlement). Viewing/downloading existing + * reports is not gated. Staff are always entitled. + */ + private async assertReportingEntitled( + user: AuthenticatedUser, + ): Promise { + if (!(await this.paymentsService.hasReportingEntitlement(user))) { + throw new ForbiddenException( + 'A reporting subscription is required to create or edit reports.', + ); + } + } + async findAll( user: AuthenticatedUser, searchTerm?: string, @@ -281,6 +298,7 @@ export class ReportsService { payload: SaveReportTemplateDto, user: AuthenticatedUser, ): Promise { + await this.assertReportingEntitled(user); const userId = user.sub; const isStaff = user.isStaff; @@ -341,6 +359,7 @@ export class ReportsService { payload: SaveReportTemplateDto, user: AuthenticatedUser, ): Promise { + await this.assertReportingEntitled(user); const userId = user.sub; const isStaff = user.isStaff; @@ -573,6 +592,7 @@ export class ReportsService { dto: RequestReportRegenerationDto, user: AuthenticatedUser, ): Promise { + await this.assertReportingEntitled(user); // 404-gates the template exactly like getHistory: a template the user // cannot view does not exist as far as they are concerned. const template = await this.findOne(id, user); diff --git a/supabase/updates/024_billing_seats_v2.sql b/supabase/updates/024_billing_seats_v2.sql new file mode 100644 index 0000000..65108db --- /dev/null +++ b/supabase/updates/024_billing_seats_v2.sql @@ -0,0 +1,95 @@ +-- 024_billing_seats_v2.sql +-- ============================================================================= +-- Billing v2: seats-only subscriptions (minimum 3 seats), manual-invoice +-- customers, and the reporting add-on. +-- +-- A) billing_customers.billing_mode 'stripe' (self-serve) | 'manual' +-- (invoiced outside Stripe; seats granted by staff) +-- B) billing_customers.reporting_* cache of the reporting add-on +-- subscription + a staff-granted reporting flag +-- C) device_licenses.stripe_subscription_id becomes nullable — +-- NULL = a seat granted by staff (never touched by Stripe reconciliation) +-- D) device_licenses.dev_eui FK gains ON UPDATE CASCADE so a licensed +-- device keeps its license when replaceDevice renames its dev_eui +-- +-- The base-subscription columns (base_subscription_id / base_status / +-- base_discount_id) are no longer written after this release; they are kept +-- for now and dropped in a later script. +-- +-- Additive and idempotent. Regenerate / patch database.types.ts (api) after. +-- ============================================================================= + +BEGIN; + +-- A) billing mode ------------------------------------------------------------- +ALTER TABLE public.billing_customers + ADD COLUMN IF NOT EXISTS billing_mode text NOT NULL DEFAULT 'stripe'; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'billing_customers_billing_mode_check' + AND conrelid = 'public.billing_customers'::regclass + ) THEN + ALTER TABLE public.billing_customers + ADD CONSTRAINT billing_customers_billing_mode_check + CHECK (billing_mode IN ('stripe', 'manual')); + END IF; +END $$; + +COMMENT ON COLUMN public.billing_customers.billing_mode IS + 'stripe = self-serve via Stripe Checkout; manual = invoiced outside Stripe, seats granted by staff (device_licenses.stripe_subscription_id IS NULL).'; + +-- B) reporting add-on --------------------------------------------------------- +ALTER TABLE public.billing_customers + ADD COLUMN IF NOT EXISTS reporting_subscription_id text, + ADD COLUMN IF NOT EXISTS reporting_status text, + ADD COLUMN IF NOT EXISTS reporting_manual boolean NOT NULL DEFAULT false; + +COMMENT ON COLUMN public.billing_customers.reporting_subscription_id IS + 'Stripe subscription id (sub_...) of the reporting add-on. Stripe is the source of truth.'; +COMMENT ON COLUMN public.billing_customers.reporting_status IS + 'Cached Stripe status of the reporting add-on (active|trialing|past_due|canceled|null).'; +COMMENT ON COLUMN public.billing_customers.reporting_manual IS + 'Staff-granted reporting entitlement (manual-invoice or comped customers). Overrides the Stripe status.'; + +-- C) staff-granted seats ------------------------------------------------------ +ALTER TABLE public.device_licenses + ALTER COLUMN stripe_subscription_id DROP NOT NULL; + +COMMENT ON COLUMN public.device_licenses.stripe_subscription_id IS + 'Stripe subscription id (sub_...) of the device-seat subscription; NULL for seats granted by staff. Stripe reconciliation never counts or deletes NULL rows.'; + +-- D) license follows a dev_eui rename ---------------------------------------- +ALTER TABLE public.device_licenses + DROP CONSTRAINT IF EXISTS device_licenses_dev_eui_fkey; + +ALTER TABLE public.device_licenses + ADD CONSTRAINT device_licenses_dev_eui_fkey + FOREIGN KEY (dev_eui) REFERENCES public.cw_devices (dev_eui) + ON UPDATE CASCADE ON DELETE SET NULL; + +COMMIT; + +-- ============================================================================= +-- OPS — run ONCE on production, after the API release is deployed. +-- Clears the TEST-mode Stripe ids (dev account) that leaked into the +-- production tables during the 2026-07-18 checkout verification. Live keys +-- do not know these ids. Verify the user id before running. +-- ============================================================================= +-- BEGIN; +-- DELETE FROM public.device_licenses +-- WHERE user_id = 'fd140e81-7640-4f42-ab52-dff1b5635723' +-- AND stripe_subscription_id LIKE 'sub_%'; +-- UPDATE public.billing_customers +-- SET stripe_customer_id = NULL, +-- base_subscription_id = NULL, +-- base_status = NULL, +-- base_discount_id = NULL, +-- device_subscription_id = NULL, +-- device_seats = 0, +-- updated_at = now() +-- WHERE user_id = 'fd140e81-7640-4f42-ab52-dff1b5635723'; +-- COMMIT; diff --git a/supabase/updates/README.md b/supabase/updates/README.md index 7c6710f..7a080a2 100644 --- a/supabase/updates/README.md +++ b/supabase/updates/README.md @@ -20,7 +20,7 @@ Full background: [`docs/security-review.md`](../../docs/security-review.md), | `005_function_hardening.sql` | Pins function `search_path`; locks SECURITY DEFINER EXECUTE to service_role | Right after 004 | | `006_remove_stripe.sql` | Drops the Stripe foreign tables, FDW server, and `wrappers` extension (Stripe is no longer used) | Any time | | `007_indexes_and_keys.sql` | Drops duplicate indexes, adds FK indexes for hot paths | Any time | -| `008_DESTRUCTIVE_legacy_table_drops.sql` | **Fully commented out.** Legacy table drops | Last, after everything is stable; take a backup first | +| `008_DESTRUCTIVE_legacy_table_drops.sql` | Legacy table drops (**applied** — the dropped tables are gone from production; `database.types.ts` still lists some of them) | Last, after everything is stable; take a backup first | | `009_remove_discord.sql` | Drops `user_discord_connections`, removes Discord notifier/communication-method rows (Discord is no longer used) | Any time | | `010_polar_device_licenses.sql` | Creates `billing_customers` + `device_licenses` for the Polar subscription/licensing feature | Before deploying the Polar API release; regenerate `database.types.ts` after | | `014_profile_preferences.sql` | Creates `profile_preferences` (1-to-1 with `profiles`) and an `auth.users.email` → `profiles.email` sync trigger for the account preferences + verified email-change feature | Before deploying the profile/preferences API release; regenerate `database.types.ts` after | @@ -31,6 +31,9 @@ Full background: [`docs/security-review.md`](../../docs/security-review.md), | `019_legal_documents.sql` | Creates `legal_documents` (versioned ToS/EULA/privacy) + `profile_legal_acceptances` (append-only audit), extends `handle_new_user()` to record signup consent (and fixes the first_name/last_name/company metadata mismatch), backfills existing users at v1 | Before deploying the legal re-acceptance API release; regenerate `database.types.ts` (api + CropWatch) after. Publish an update later via the OPS `UPDATE` in the script footer | | `020_whats_new.sql` | Creates `whats_new` (single-row announcement flag, seeded at release 0) + `profile_whats_new_seen` (per-user dismiss tracking), extends `handle_new_user()` to pre-seed new signups as already-seen | **After 019.** Before deploying the What's New API release; regenerate `database.types.ts` (api + CropWatch) after. Activate an announcement via the OPS `UPDATE` in the script footer, only after the app deploy containing the matching content | | `021_scheduled_legal_updates.sql` | Creates `legal_document_versions` (published + scheduled versions per document; the current version is the highest one whose `effective_at` has passed, so future-dated inserts activate the re-accept gate on their own, several documents at once when they share an `effective_at`), seeds it from `legal_documents` (which stays as the kind registry, its version columns deprecated), points `handle_new_user()` at it | **After 020.** Run before deploying the scheduled-legal-updates API release; regenerate `database.types.ts` after. Only schedule updates once that release is live — via `scripts/Update-Legal.sql` | +| `022_push_tokens.sql` | Creates `cw_push_tokens` (FCM web-push token registry) | Before deploying the push-notification API release | +| `023_push_action_type.sql` | Seeds `cw_rule_action_types` with the Push action — data-driven rules-UI option | **Last** — only after the alert service handles push actions | +| `024_billing_seats_v2.sql` | Billing v2: `billing_customers.billing_mode` (`stripe`/`manual`), `reporting_subscription_id`/`reporting_status`/`reporting_manual`; `device_licenses.stripe_subscription_id` nullable (NULL = staff-granted seat); license FK `ON UPDATE CASCADE`. Additive. Footer holds a one-off OPS block that clears the test-mode Stripe ids that leaked into prod | Main block **before** deploying the billing-v2 API release; OPS block after. Patch `database.types.ts` (api + CropWatch) after | ## Deploy/run interleaving (critical)