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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions database.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
33 changes: 24 additions & 9 deletions llms.txt
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
97 changes: 73 additions & 24 deletions scripts/stripe-bootstrap.mjs
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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;
}

Expand All @@ -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.`,
);
34 changes: 34 additions & 0 deletions src/v1/auth/guards/staff.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -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: '[email protected]', isStaff: true }),
),
).toBe(true);
});

it('rejects non-staff users', () => {
expect(() =>
guard.canActivate(
contextFor({ sub: 'u1', email: '[email protected]', isStaff: false }),
),
).toThrow(ForbiddenException);
});

it('rejects requests with no authenticated user', () => {
expect(() => guard.canActivate(contextFor(undefined))).toThrow(
ForbiddenException,
);
});
});
27 changes: 27 additions & 0 deletions src/v1/auth/guards/staff.guard.ts
Original file line number Diff line number Diff line change
@@ -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<Request & { user?: AuthenticatedUser }>();
if (!request.user?.isStaff) {
throw new ForbiddenException('Staff only');
}
return true;
}
}
3 changes: 2 additions & 1 deletion src/v1/devices/devices.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,8 @@
*,
owner_match:cw_device_owners(),
cw_device_owners(*),
cw_locations(name, location_id)
cw_locations(name, location_id),
device_licenses(id)
`,
{ count: 'exact' },
);
Expand Down Expand Up @@ -231,7 +232,7 @@
let offlineCount = 0;

devices.forEach((device) => {
const lastUpdated = new Date(device.last_data_updated_at);

Check warning on line 235 in src/v1/devices/devices.service.ts

View workflow job for this annotation

GitHub Actions / build

Unsafe argument of type `any` assigned to a parameter of type `string | number | Date`
const minutesSinceLastUpdate =
(now.getTime() - lastUpdated.getTime()) / (1000 * 60);
const deviceType = Array.isArray(device.cw_device_type)
Expand Down
3 changes: 1 addition & 2 deletions src/v1/locations/locations.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
Loading
Loading