diff --git a/docs/api/billing.md b/docs/api/billing.md index 57178d7..e5550c4 100644 --- a/docs/api/billing.md +++ b/docs/api/billing.md @@ -1,6 +1,6 @@ # Billing API -The Billing API provides access to ledger entries, balance, and payment history. All endpoints are tenant-scoped — they require a valid JWT and `x-tenant-id` header. +The Billing API provides access to ledger entries, balance, and payment history. All endpoints are tenant-scoped - they require a valid JWT and `x-tenant-id` header. --- @@ -16,7 +16,7 @@ Returns paginated ledger entries for the tenant, newest first. | :-------- | :----- | :------ | :---------------------------------------------------------- | | `page` | number | 1 | Page number | | `limit` | number | 20 | Results per page (max 100) | -| `type` | string | — | Filter by type: CHARGE, PAYMENT, CREDIT, REFUND, ADJUSTMENT | +| `type` | string | - | Filter by type: CHARGE, PAYMENT, CREDIT, REFUND, ADJUSTMENT | ### Response @@ -78,7 +78,7 @@ Returns paginated payment attempts for the tenant, newest first. | :-------- | :----- | :------ | :------------------------------------------------------------- | | `page` | number | 1 | Page number | | `limit` | number | 20 | Results per page (max 100) | -| `status` | string | — | Filter: PENDING, SUCCEEDED, FAILED, CANCELLED, REQUIRES_ACTION | +| `status` | string | - | Filter: PENDING, SUCCEEDED, FAILED, CANCELLED, REQUIRES_ACTION | ### Response @@ -155,7 +155,7 @@ Returns a single payment attempt with full provider response. ## Webhook Endpoints -These endpoints receive events from payment providers. They have NO authentication guard — the provider's signature is the authentication. +These endpoints receive events from payment providers. They have NO authentication guard - the provider's signature is the authentication. ### Stripe Webhook diff --git a/docs/architecture/phase-5-payments-webhooks.md b/docs/architecture/phase-5-payments-webhooks.md index d98b921..d94065d 100644 --- a/docs/architecture/phase-5-payments-webhooks.md +++ b/docs/architecture/phase-5-payments-webhooks.md @@ -6,7 +6,7 @@ The payment system collects money for invoices. It integrates with payment provi ## Why this exists -Phase 4 generates invoices and tracks what tenants owe. But `mark-paid` was manual — someone had to call an endpoint to confirm payment. Phase 5 automates this: finalize an invoice → create a payment intent → provider charges the card → webhook confirms → invoice marked PAID → billing period advances. +Phase 4 generates invoices and tracks what tenants owe. But `mark-paid` was manual - someone had to call an endpoint to confirm payment. Phase 5 automates this: finalize an invoice → create a payment intent → provider charges the card → webhook confirms → invoice marked PAID → billing period advances. --- @@ -55,7 +55,7 @@ Two new tables: - **Invoice → PaymentAttempts:** one-to-many. One invoice can have multiple attempts (retries after failure). - **Tenant → PaymentAttempts:** one-to-many. Denormalized `tenant_id` for filtering without JOIN. -- **WebhookEvents:** standalone. Not FK'd to anything — the provider event ID is the deduplication key. +- **WebhookEvents:** standalone. Not FK'd to anything - the provider event ID is the deduplication key. --- @@ -83,7 +83,7 @@ Two implementations: | `FakePaymentAdapter` | `PAYMENT_PROVIDER=fake` | In-memory, no external calls. Configurable success rate. | | `StripePaymentAdapter` | `PAYMENT_PROVIDER=stripe` | Real Stripe API. Signature validation on webhooks. | -The adapter is injected via `PAYMENT_PROVIDER` token. Swapping providers requires zero code changes — just change the env var. +The adapter is injected via `PAYMENT_PROVIDER` token. Swapping providers requires zero code changes - just change the env var. ### Why an adapter and not direct Stripe calls @@ -160,7 +160,7 @@ Three problems every webhook receiver must solve: **Duplicates:** The `provider_event_id` column on `webhook_events` has a UNIQUE constraint. Before processing, we check if the event already exists. If it does and status is PROCESSED, skip. This is the deduplication gate. -**Out-of-order:** The `PaymentSuccessHandler` looks up the payment attempt by `provider_payment_id`. If the attempt doesn't exist yet (race condition), the handler logs a warning and returns — the event is marked PROCESSED to prevent retries. The payment attempt creation will handle the success state independently since the fake adapter returns the final status synchronously. +**Out-of-order:** The `PaymentSuccessHandler` looks up the payment attempt by `provider_payment_id`. If the attempt doesn't exist yet (race condition), the handler logs a warning and returns - the event is marked PROCESSED to prevent retries. The payment attempt creation will handle the success state independently since the fake adapter returns the final status synchronously. **Timeouts:** The webhook endpoint returns 200 immediately, then processes asynchronously. The provider sees success and doesn't retry. Processing happens in the background. @@ -210,7 +210,7 @@ WebhookEvent statuses: ## API endpoints -### Webhooks (no auth — signature is the auth) +### Webhooks (no auth - signature is the auth) | Method | Path | Description | | ------ | ---------------- | ------------------------------ | diff --git a/docs/phases/phase-5.md b/docs/phases/phase-5.md index 91a9e8a..7585a44 100644 --- a/docs/phases/phase-5.md +++ b/docs/phases/phase-5.md @@ -25,14 +25,14 @@ Phase 5 is complete. Here's what was built: Abstract `PaymentProviderBase` with four methods: `createPaymentIntent`, `getPaymentStatus`, `refundPayment`, `constructWebhookEvent`. Two implementations: -- **FakePaymentAdapter** — in-memory, no external calls. Configurable success rate via `FAKE_PAYMENT_SUCCESS_RATE` env var. Used in dev/test. -- **StripePaymentAdapter** — real Stripe API calls. Signature validation on webhooks via `stripe-signature` header. +- **FakePaymentAdapter** - in-memory, no external calls. Configurable success rate via `FAKE_PAYMENT_SUCCESS_RATE` env var. Used in dev/test. +- **StripePaymentAdapter** - real Stripe API calls. Signature validation on webhooks via `stripe-signature` header. Provider selected via `PAYMENT_PROVIDER` env var. Injected as `PAYMENT_PROVIDER` token. ### Webhook processing -Single entry point: `POST /api/v1/webhooks/stripe` (or `/fake` for testing). No auth guard — signature validation IS the authentication. +Single entry point: `POST /api/v1/webhooks/stripe` (or `/fake` for testing). No auth guard - signature validation IS the authentication. Processing flow: return 200 immediately → validate signature → check dedup → route by event type → update status. Three problem areas handled: @@ -42,7 +42,7 @@ Processing flow: return 200 immediately → validate signature → check dedup ### Payment success flow -`payment_intent.succeeded` webhook → find payment attempt by `provider_payment_id` → update to SUCCEEDED → call `invoiceLifecycle.markPaid()` (creates ledger PAYMENT entry) → call `billingPeriod.advancePeriod()`. Period advance is wrapped in try/catch — if it fails, invoice is still PAID (correct state) and the billing cron catches it next tick. +`payment_intent.succeeded` webhook → find payment attempt by `provider_payment_id` → update to SUCCEEDED → call `invoiceLifecycle.markPaid()` (creates ledger PAYMENT entry) → call `billingPeriod.advancePeriod()`. Period advance is wrapped in try/catch - if it fails, invoice is still PAID (correct state) and the billing cron catches it next tick. ### Payment failure flow @@ -54,8 +54,8 @@ Retry schedule: attempt 0 (original) → 1 day → attempt 1 → 3 days → atte Added to `BillingLedgerController`: -- `GET /api/v1/billing/payments` — paginated, filterable by status. Includes `invoiceNumber` via join. -- `GET /api/v1/billing/payments/:id` — full details including `providerResponse`. +- `GET /api/v1/billing/payments` - paginated, filterable by status. Includes `invoiceNumber` via join. +- `GET /api/v1/billing/payments/:id` - full details including `providerResponse`. Both tenant-scoped via JWT + TenantGuard. @@ -101,14 +101,14 @@ Two new tables: `payment_attempts` (tracks each payment attempt with status, pro ## Gotchas encountered -1. **Payment history endpoints in BillingLedgerController** — initially looked for them in the payments module. They live in the invoices module's `BillingLedgerController` since they share the `/billing/` route prefix and tenant-scoping pattern. -2. **WebhookEventResponseDto unused** — DTO created ahead of the admin webhook listing endpoint (Phase 6). Exported from dto barrel but not consumed by any controller yet. Left intentionally for forward compatibility. -3. **Raw body requirement for Stripe signatures** — Stripe signature validation needs the exact bytes received, not parsed JSON. Required raw body parser middleware configuration. +1. **Payment history endpoints in BillingLedgerController** - initially looked for them in the payments module. They live in the invoices module's `BillingLedgerController` since they share the `/billing/` route prefix and tenant-scoping pattern. +2. **WebhookEventResponseDto unused** - DTO created ahead of the admin webhook listing endpoint (Phase 6). Exported from dto barrel but not consumed by any controller yet. Left intentionally for forward compatibility. +3. **Raw body requirement for Stripe signatures** - Stripe signature validation needs the exact bytes received, not parsed JSON. Required raw body parser middleware configuration. ## Limitations carried into next phases -- **No refund endpoint** — `RefundResult` type exists in the adapter but no API endpoint to trigger refunds. -- **No admin webhook viewer** — `WebhookEventResponseDto` is pre-built but no controller exposes it yet. -- **No payment method management** — no Stripe Customer/SetupIntent integration for storing cards. -- **No dunning emails** — payment failures are tracked but no notifications sent. -- **No SCA/3DS** — `REQUIRES_ACTION` status exists in the enum but no flow handles it. +- **No refund endpoint** - `RefundResult` type exists in the adapter but no API endpoint to trigger refunds. +- **No admin webhook viewer** - `WebhookEventResponseDto` is pre-built but no controller exposes it yet. +- **No payment method management** - no Stripe Customer/SetupIntent integration for storing cards. +- **No dunning emails** - payment failures are tracked but no notifications sent. +- **No SCA/3DS** - `REQUIRES_ACTION` status exists in the enum but no flow handles it. diff --git a/src/common/constants/error-messages.ts b/src/common/constants/error-messages.ts index bdbace3..579b20d 100644 --- a/src/common/constants/error-messages.ts +++ b/src/common/constants/error-messages.ts @@ -20,6 +20,7 @@ export const ERRORS = { SLUG_EXISTS: (slug: string) => `Tenant with slug "${slug}" already exists`, NOT_FOUND_ID: (id: string) => `Tenant with ID "${id}" not found`, NOT_FOUND_SLUG: (slug: string) => `Tenant with slug "${slug}" not found`, + ID_MISMATCH: 'URL tenant ID does not match x-tenant-id header', }, /** * User related error messages diff --git a/src/modules/tenants/tenants.controller.ts b/src/modules/tenants/tenants.controller.ts index fbc9cd6..a091b22 100644 --- a/src/modules/tenants/tenants.controller.ts +++ b/src/modules/tenants/tenants.controller.ts @@ -29,6 +29,7 @@ import { Body, Controller, Delete, + ForbiddenException, Get, HttpCode, HttpStatus, @@ -49,7 +50,8 @@ import { ApiTags, } from '@nestjs/swagger'; -import { CurrentUser, Roles } from '@common/decorators'; +import { ERRORS } from '@common/constants'; +import { CurrentUser, Roles, TenantId } from '@common/decorators'; import { ErrorResponseDto } from '@common/dto'; import { RolesGuard, TenantGuard } from '@common/guards'; @@ -262,8 +264,13 @@ export class TenantsController { }) async update( @Param('id', ParseUUIDPipe) id: string, + @TenantId() tenantId: string, @Body() dto: UpdateTenantDto, ) { + if (id !== tenantId) { + throw new ForbiddenException(ERRORS.TENANT.ID_MISMATCH); + } + return this.tenantsService.update(id, dto); } @@ -302,7 +309,14 @@ export class TenantsController { description: 'Tenant not found', type: ErrorResponseDto, }) - async remove(@Param('id', ParseUUIDPipe) id: string) { + async remove( + @Param('id', ParseUUIDPipe) id: string, + @TenantId() tenantId: string, + ) { + if (id !== tenantId) { + throw new ForbiddenException(ERRORS.TENANT.ID_MISMATCH); + } + return this.tenantsService.remove(id); } }