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
8 changes: 4 additions & 4 deletions docs/api/billing.md
Original file line number Diff line number Diff line change
@@ -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.

---

Expand All @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
10 changes: 5 additions & 5 deletions docs/architecture/phase-5-payments-webhooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down Expand Up @@ -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.

---

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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 |
| ------ | ---------------- | ------------------------------ |
Expand Down
28 changes: 14 additions & 14 deletions docs/phases/phase-5.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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

Expand All @@ -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.

Expand Down Expand Up @@ -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.
1 change: 1 addition & 0 deletions src/common/constants/error-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 16 additions & 2 deletions src/modules/tenants/tenants.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
HttpCode,
HttpStatus,
Expand All @@ -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';

Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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);
}
}
Loading