Skip to content
Open
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
23 changes: 23 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,29 @@ ESP_CREDENTIALS_ENCRYPTION_KEY=
# service. Its API key is shown exactly once in `docker compose logs init`.
SUPER_ADMIN_EMAIL=admin@example.com

# Explicit billing mode. Use oss for self-hosted installs. Cloud requires the
# provider/catalog variables documented in apps/api/.env.example.
SENDLIT_DEPLOYMENT_MODE=oss
# Amounts are integer minor units (cents for USD). Raising a price requires a
# new provider product ID and a higher BILLING_CATALOG_REVISION. Paid amounts
# are never source constants. See apps/api/.env.example.

# Optional cloud fair-use controls (defaults match the published policy).
# BILLING_FAIR_USE_MIN_ACCEPTED=500
# BILLING_FAIR_USE_BOUNCE_WARN_BPS=200
# BILLING_FAIR_USE_COMPLAINT_WARN_BPS=5
# BILLING_FAIR_USE_BOUNCE_PAUSE_BPS=500
# BILLING_FAIR_USE_COMPLAINT_PAUSE_BPS=10
# BILLING_FAIR_USE_COMPLAINT_STOP_BPS=30
# BILLING_FAIR_USE_COMPLAINT_STOP_ABSOLUTE=10
# BILLING_FAIR_USE_TRANSACTIONAL_DAILY_LIMIT=100
# BILLING_FAIR_USE_MINIMUM_HOLD_HOURS=72
# BILLING_FAIR_USE_RECOVERY_CLEAN_DAYS=7
# BILLING_RAMP_DAYS_0_2_LIMIT=200
# BILLING_RAMP_DAYS_3_6_LIMIT=1000
# BILLING_RAMP_DAYS_7_13_LIMIT=10000
# BILLING_TEST_VOLUME_THRESHOLD=100

# Public origins. For a local installation, keep these defaults. For a public
# deployment, use the externally reachable HTTPS origins and set PROTOCOL=https.
API_PUBLIC_URL=http://localhost:5000
Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
- Don't duplicate stuff over and over. Re-use existing code and libraries.
- While making changes to the `apps/api` directory, make sure the REST API documentation and MCP server are updated as well.
- For UI components, use shadcn/ui exclusively. Always use Shadcn CLI for installing components. Never hand roll standard Shadcn components. Prefer shadcn/ui components over browser-native components.
- When dealing with a large change, work through it in layers: money-path correctness first, then enforcement, then dashboard/self-serve, then ops and docs.
- If you are a Grok model, make sure you run the linter and tests before declaring any task done.

## Architecture Tips

Expand Down
73 changes: 65 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,73 @@ analytics, bounce handling and multi-user accounts are still on the roadmap.
- `packages/email-blocks` — headless composing blocks for broadcasts/
sequences/templates (`@sendlit/email-blocks`), used by `apps/web`.

## Running everything locally

1. Start Postgres and Redis (e.g. via Docker).
2. `apps/api`: copy `.env.example` to `.env`, fill in the values, then
`pnpm --filter @sendlit/api db:push` and `pnpm --filter @sendlit/api dev`.
3. `apps/web`: copy `.env.example` to `.env.local` (`API_URL` pointing at the
API above), then `pnpm --filter @sendlit/web dev`.
4. Build the two shared packages at least once so `apps/web` has something to
## Local development

Start Postgres and Redis, then run the API and web app on the host.

```sh
docker run -d --name sendlit-postgres --restart unless-stopped \
-e POSTGRES_DB=sendlit \
-e POSTGRES_USER=sendlit \
-e POSTGRES_PASSWORD=sendlit \
-p 5432:5432 \
postgres:17-alpine

docker run -d --name sendlit-redis --restart unless-stopped \
-p 6379:6379 \
redis:7-alpine redis-server --appendonly yes
```

These ports match `apps/api/.env.example` (`localhost:5432` and `localhost:6379`).
If a host port is already in use, change the left-hand side of `-p` and update
`DB_CONNECTION_STRING` or `REDIS_PORT` to match.

To wipe the local Postgres data and start over, remove the container (and its
volume) and run the `docker run` command again:

```sh
docker rm -fv sendlit-postgres
```

Then:

1. `apps/api`: copy `.env.example` to `.env` and fill in the values, then
`pnpm --filter @sendlit/api db:push`.
2. `apps/web`: copy `.env.example` to `.env.local` (`API_URL` pointing at the
API above).
3. Build the two shared packages at least once so `apps/web` has something to
import: `pnpm --filter @sendlit/email-editor build && pnpm --filter @sendlit/email-blocks build`
(re-run, or use their `dev` scripts, after changing either package).
4. From the repo root, start the apps you need:

```sh
pnpm dev:api
pnpm dev:web
pnpm dev:docs
```

## Operator billing CLI

Cloud billing recovery is a CLI, not the dashboard. From the repo root it
loads `apps/api/.env` and talks to that API's database:

```sh
pnpm --filter @sendlit/api billing catalog-status
pnpm --filter @sendlit/api billing catalog-verify
```

Run it with no arguments for the full list. Subcommands:

- `catalog-status` / `catalog-verify` / `catalog-abandon <revision> --reason <text>`
- `reconcile-org <organization_public_id>`
- `webhook-retry <provider_event_id>` / `webhook-inspect <provider_event_id>`
- `set-override <organization_public_id> --teams <n>|none --contacts <n>|none --reason <text>`
- `reputation-apply <team_public_id> <warned|marketing_paused|all_paused> --operator <user_id> --reason <text>`
- `reputation-release <team_public_id> --operator <user_id> --reason <text>`
- `cancel-subscription <organization_public_id> --reason <text>`

OSS mode has nothing to verify. A new `BILLING_CATALOG_REVISION` is recorded on
API startup; `catalog-verify` checks it against Dodo and activates it.

## Self-hosting with Docker Compose

Expand Down
54 changes: 54 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,60 @@ REDIS_PORT=6379
PORT=5000
NODE_ENV=development

# Billing deployment mode is explicit. Use oss for local/self-hosted installs.
SENDLIT_DEPLOYMENT_MODE=oss
# Cloud-only catalog/provider settings (required when mode=cloud):
# BILLING_CHECKOUT_PROVIDER=dodo
# BILLING_ENABLED_PROVIDERS=dodo,stripe
# Tests may use BILLING_CHECKOUT_PROVIDER=fake. Production cloud refuses fake.
# BILLING_CATALOG_REVISION=<positive integer>
# BILLING_CURRENCY=<ISO 4217 code>
# BILLING_PRO_MONTH_AMOUNT_MINOR=<positive integer>
# BILLING_PRO_YEAR_AMOUNT_MINOR=<positive integer>
# BILLING_BUSINESS_MONTH_AMOUNT_MINOR=<positive integer>
# BILLING_BUSINESS_YEAR_AMOUNT_MINOR=<positive integer>
# DODO_PAYMENTS_API_KEY=
# DODO_PAYMENTS_WEBHOOK_KEY_CURRENT=
# DODO_PAYMENTS_WEBHOOK_KEY_PREVIOUS=
# DODO_PAYMENTS_WEBHOOK_KEY_PREVIOUS_EXPIRES_AT=
# DODO_PAYMENTS_ENVIRONMENT=test_mode
# Maximum age of the sign-in that may mint a single-use billing action token.
# BILLING_RECENT_AUTH_MAX_AGE_SECONDS=900
# DODO_PRO_MONTH_PRODUCT_ID=
# DODO_PRO_YEAR_PRODUCT_ID=
# DODO_BUSINESS_MONTH_PRODUCT_ID=
# DODO_BUSINESS_YEAR_PRODUCT_ID=
# Billing checkout URLs and webhook payloads are encrypted at rest.
# BILLING_DATA_ENCRYPTION_KEY= # base64 encoding of exactly 32 random bytes
# BILLING_DATA_ENCRYPTION_KEY_VERSION=v1
# BILLING_DATA_ENCRYPTION_KEY_PREVIOUS= # optional during key rotation
# Admin paging for billing SLOs (comma-separated). Falls back to SUPER_ADMIN_EMAIL.
# BILLING_ALERT_EMAIL=
# Operator recovery (catalog verify/abandon, webhook retry, overrides,
# reputation release, emergency cancel) is `pnpm --filter @sendlit/api billing`.
# Required in cloud mode. Dedicated HMAC key for one-time trial eligibility.
# Do not reuse BETTER_AUTH_SECRET. Generate with: openssl rand -base64 32
# BILLING_TRIAL_EMAIL_HMAC_KEY=
# BILLING_TRIAL_EMAIL_HMAC_KEY_VERSION=v1
# BILLING_TRIAL_EMAIL_HMAC_KEY_PREVIOUS=
# BILLING_TRIAL_EMAIL_HMAC_KEY_PREVIOUS_VERSION=
# Fair-use controls (defaults match the published policy; override per deploy).
# BILLING_FAIR_USE_MIN_ACCEPTED=500
# BILLING_FAIR_USE_BOUNCE_WARN_BPS=200
# BILLING_FAIR_USE_COMPLAINT_WARN_BPS=5
# BILLING_FAIR_USE_BOUNCE_PAUSE_BPS=500
# BILLING_FAIR_USE_COMPLAINT_PAUSE_BPS=10
# BILLING_FAIR_USE_COMPLAINT_STOP_BPS=30
# BILLING_FAIR_USE_COMPLAINT_STOP_ABSOLUTE=10
# BILLING_FAIR_USE_TRANSACTIONAL_DAILY_LIMIT=100
# BILLING_FAIR_USE_MINIMUM_HOLD_HOURS=72
# BILLING_FAIR_USE_RECOVERY_CLEAN_DAYS=7
# Paid marketing ramp (messages per organization per UTC day).
# BILLING_RAMP_DAYS_0_2_LIMIT=200
# BILLING_RAMP_DAYS_3_6_LIMIT=1000
# BILLING_RAMP_DAYS_7_13_LIMIT=10000
# BILLING_TEST_VOLUME_THRESHOLD=100

# Public URL Better Auth uses for OAuth callbacks, issuers and trusted origins.
API_PUBLIC_URL=http://localhost:5000

Expand Down
1 change: 1 addition & 0 deletions apps/api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ COPY --from=deps /app/ ./
RUN pnpm --filter=@sendlit/email-editor build
RUN pnpm --filter=@sendlit/api-contract build
RUN pnpm --filter=@sendlit/email-blocks build
RUN pnpm --filter=@sendlit/api billing:generate
RUN pnpm --filter=@sendlit/api build

FROM base AS runner
Expand Down
50 changes: 50 additions & 0 deletions apps/api/billing.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { defineBillingConfig } from "@codelitdev/billing/config";

/** Schema description for `codelit-billing generate`. Output is committed and
* applied through drizzle-kit; SendLit does not hand-write canonical tables. */
export default defineBillingConfig({
dialect: "postgresql",
adapter: "drizzle",
output: "./src/db/billing.generated.ts",
billableEntity: {
modelName: "organization",
tableImport: "./schema-core",
tableExport: "organizations",
idColumn: "id",
idType: "uuid",
onDelete: "restrict",
},
payer: {
modelName: "user",
tableImport: "./schema-core",
tableExport: "user",
idColumn: "id",
idType: "text",
onDelete: "restrict",
},
planIds: ["pro", "business"],
requiredOfferKeys: [
"pro_month",
"pro_year",
"business_month",
"business_year",
],
additionalFields: {
planStates: {
plan: { type: "text", nullable: false },
teamsLimitOverride: { type: "integer", nullable: true },
contactsLimitOverride: { type: "integer", nullable: true },
firstPaidActivatedAt: { type: "timestamp", nullable: true },
rampStage: { type: "integer", nullable: false },
rampCleanStageDays: { type: "integer", nullable: false },
rampEvaluatedAt: { type: "timestamp", nullable: true },
},
checkoutAttempts: {
pendingTeamName: { type: "text", nullable: true },
},
subscriptions: {
pastDueAt: { type: "timestamp", nullable: true },
graceEndsAt: { type: "timestamp", nullable: true },
},
},
});
5 changes: 5 additions & 0 deletions apps/api/docs/organizations.md
Original file line number Diff line number Diff line change
Expand Up @@ -1526,6 +1526,11 @@ DELETE /organizations/:organizationId
```

- Signup automatically creates the first organization and owner membership.
- Any automatically-created initial/default team is named from its
organization (`<organization name> Team`) unless a flow supplies an explicit
team name.
- An owner cannot create another active, suspended, or pending organization
whose name differs only by casing; closed and abandoned names may be reused.
- Additional organization creation requires an authenticated user.
- Responses contain public organization data only.
- `DELETE` is owner-only, audited, and changes status to `closed`.
Expand Down
Loading
Loading