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
43 changes: 23 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,12 @@ It covers the full backend lifecycle: authentication, workspaces, workspace memb
- GitHub **issue** webhooks are parsed into `integration_tasks`: an issue `opened` creates a task (status `open`), and `closed` updates it to status `closed`.

- **Billing & Subscriptions (Stripe)**
- Checkout session creation per workspace (`POST /workspaces/:id/checkout`).
- Lazy **Stripe Customer** creation, stored on the workspace.
- Checkout session creation for the logged-in user (`POST /checkout`).
- Lazy **Stripe Customer** creation, stored on the user.
- Stripe **webhooks**: `checkout.session.completed`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.payment_failed`.
- Subscription plans: `PRO`, `BUSINESS`, `ENTERPRISE`; statuses `ACTIVE` / `INACTIVE`.
- `GET /workspaces/:id/subscription` returns the workspace's active subscription.
- Subscriptions are owned by the **user** and apply across the whole account.
- `GET /subscription` returns the logged-in user's active subscription.
- Payment-failure notifications via Resend email.

- **Consistent API design**
Expand Down Expand Up @@ -278,7 +279,7 @@ Response `200` has the same `{ "message", "user": { "user", "token" } }` shape.
| `POST` | `/workspaces` | Create a workspace (creator becomes ADMIN member) |
| `PATCH` | `/workspaces/:id` | Update workspace name/description |
| `DELETE` | `/workspaces/:id` | Delete a workspace |
| `POST` | `/workspaces/:id/checkout` | Create a Stripe checkout session for this workspace |
| `POST` | `/checkout` | Create a Stripe checkout session for the logged-in user |

#### Create — `POST /workspaces`

Expand All @@ -293,7 +294,7 @@ Response `201`:
"message": "Workspace created successfully",
"workspace": {
"id": "d949ce46-...", "workspace_name": "SupaGo", "description": "Go backend team",
"user_id": "...", "created_at": "...", "updated_at": null, "stripe_customer_id": null
"user_id": "...", "created_at": "...", "updated_at": null
}
}
```
Expand Down Expand Up @@ -487,25 +488,27 @@ Response `200`:

### Subscriptions (Billing)

Subscriptions belong to the **logged-in user** and their PRO status applies across the whole account.

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/workspaces/:id/subscription` | Get the workspace's active subscription |
| `POST` | `/workspaces/:id/checkout` | Start a Stripe checkout for a PRO subscription |
| `GET` | `/subscription` | Get the logged-in user's active subscription |
| `POST` | `/checkout` | Start a Stripe checkout for a PRO subscription |

#### Get subscription — `GET /workspaces/:id/subscription`
#### Get subscription — `GET /subscription`

```json
{
"subscription": {
"id": "...", "workspaceId": "...", "stripeSubscriptionId": "sub_xxx", "stripePriceId": "price_xxx",
"id": "...", "userId": "...", "stripeSubscriptionId": "sub_xxx", "stripePriceId": "price_xxx",
"status": "ACTIVE", "plan": "PRO",
"currentPeriodStart": "2026-08-13T00:00:00Z", "currentPeriodEnd": "2026-09-13T00:00:00Z",
"createdAt": "...", "updatedAt": "..."
}
}
```

> Returns `WORKSPACE_NOT_FOUND` if the workspace doesn't belong to the user, or `SUBSCRIPTION_NOT_FOUND` if no active subscription exists.
> Returns `SUBSCRIPTION_NOT_FOUND` if the user has no active subscription. The frontend should treat a `404` here as "free plan", not as an error.

---

Expand Down Expand Up @@ -565,18 +568,18 @@ Connect to `GET /api/v1/ws` (authenticated) and send a JSON message:

## Stripe Billing Flow

1. **Checkout** — `POST /workspaces/:id/checkout`:
- If the workspace has no Stripe customer yet, a customer is created and saved via `UpdateWorkspaceStripeCustomer`.
1. **Checkout** — `POST /checkout`:
- If the logged-in user has no Stripe customer yet, a customer is created and saved via `UpdateUserStripeCustomer`.
- Returns `{ "url": "<checkout_session_url>" }` for the PRO price (`STRIPE_PRO_PRICE_ID`).
- The checkout session carries `workspace_id` in its metadata.
- The checkout session carries `user_id` in its metadata.

2. **Webhook: `checkout.session.completed`** — reads `workspace_id` metadata, retrieves the Stripe subscription, and inserts a `subscriptions` row (`status=ACTIVE`, `plan=PRO`, current period = now → +1 month).
2. **Webhook: `checkout.session.completed`** — reads `user_id` metadata, retrieves the Stripe subscription, and inserts a `subscriptions` row (`status=ACTIVE`, `plan=PRO`, current period = now → +1 month), owned by that user.

3. **Webhook: `customer.subscription.updated`** — updates the subscription's price/status/period.

4. **Webhook: `customer.subscription.deleted`** — sets the subscription `status=INACTIVE`.

5. **Webhook: `invoice.payment_failed`** — looks up the user by the customer's Stripe ID (`GetUserByStripeCustomerID`) and sends a Resend "Payment Failed" email.
5. **Webhook: `invoice.payment_failed`** — looks up the user by their Stripe customer ID (`GetUserByStripeCustomerID`) and sends a Resend "Payment Failed" email.

Webhook payloads are verified against `STRIPE_WEBHOOK_SECRET` with `webhook.ConstructEventWithOptions`.

Expand All @@ -598,19 +601,19 @@ Outgoing calls to `api.github.com` include `User-Agent: gin-api` and (when `GITH

## Database Schema

Ten tables (migrations `00001`–`00012`), all keyed by **UUID** primary keys:
Ten tables (migrations `00001`–`00013`), all keyed by **UUID** primary keys:

```
users (id, first_name, last_name, email UNIQUE, password, created_at, updated_at)
workspaces (id, workspace_name, description, user_id FK→users, created_at, updated_at, stripe_customer_id)
users (id, first_name, last_name, email UNIQUE, password, created_at, updated_at, stripe_customer_id)
workspaces (id, workspace_name, description, user_id FK→users, created_at, updated_at)
workspace_members (id, user_id FK→users, workspace_id FK→workspaces, user_role CHECK IN ('ADMIN','MEMBER'), created_at)
projects (id, name, description, workspace_id FK→workspaces, created_at, updated_at)
tasks (id, name, description, start_date, end_date,
status CHECK IN ('TODO','IN_PROGRESS','TESTING','DONE'),
priority CHECK IN ('LOW','MEDIUM','HIGH','URGENT'),
project_id FK→projects, assignee_id FK→users, created_at, updated_at)
messages (id, sender_id FK→users, receiver_id FK→users, content, created_at)
subscriptions (id, workspace_id FK→workspaces, stripe_subscription_id UNIQUE, stripe_price_id,
subscriptions (id, user_id FK→users, stripe_subscription_id UNIQUE, stripe_price_id,
status CHECK IN ('ACTIVE','INACTIVE'), plan CHECK IN ('PRO','BUSINESS','ENTERPRISE'),
current_period_start, current_period_end, created_at, updated_at)
project_integrations (id, project_id FK→projects ON DELETE CASCADE, provider CHECK IN ('github','gitlab','jira'),
Expand All @@ -623,7 +626,7 @@ integration_tasks (id, project_id FK→projects ON DELETE CASCADE, provider CHEC
payload JSONB, created_at, updated_at)
```

Migrations `00009`/`00010`/`00012` add the integration tables; `00011` indexes `project_integrations` on `(provider, repository_owner, repository_name)` for fast webhook lookup.
Migrations `00009`/`00010`/`00012` add the integration tables; `00011` indexes `project_integrations` on `(provider, repository_owner, repository_name)` for fast webhook lookup. Migration `00013` moves subscription ownership from `workspaces` to `users` (subscriptions now reference `users.id`, and `stripe_customer_id` moved from `workspaces` to `users`).

---

Expand Down
4 changes: 2 additions & 2 deletions cmd/api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,10 @@ func (app *application) routes() http.Handler {
authGroup.POST("/workspaces", workspaceHandler.CreateWorkspace)
authGroup.PATCH("/workspaces/:id", workspaceHandler.UpdateWorkspace)
authGroup.DELETE("/workspaces/:id", workspaceHandler.DeleteWorkspace)
authGroup.POST("/workspaces/:id/checkout", workspaceHandler.CreateCheckoutSession)
authGroup.POST("/checkout", workspaceHandler.CreateCheckoutSession)

/* Subscriptions routes */
authGroup.GET("/workspaces/:id/subscription", subscriptionsHandler.GetWorkspaceSubscription)
authGroup.GET("/subscription", subscriptionsHandler.GetUserSubscription)

/* Workspace Members routes */
authGroup.GET("/workspaces/:id/members", workspaceMembersHandler.GetWorkspaceMembers)
Expand Down
16 changes: 0 additions & 16 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,22 +30,6 @@ services:
interval: 10s
timeout: 5s
retries: 5

app:
build:
context: .
dockerfile: Dockerfile
container_name: gin-api-app
restart: unless-stopped
env_file:
- .env
environment:
GOOSE_DBSTRING: host=postgres user=${DATABASE_USERNAME} password=${DATABASE_PASSWORD} dbname=${DATABASE_NAME} sslmode=disable
ports:
- "${PORT:-3700}:${PORT:-3700}"
depends_on:
postgres:
condition: service_healthy
volumes:
postgres_data:
redis_data:
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
-- +goose Up
-- Move subscription ownership from workspace to user.
-- The Stripe customer also moves from the workspace to the user.

-- 1. Add stripe_customer_id to users (mirrors what workspaces had).
ALTER TABLE users
ADD COLUMN stripe_customer_id VARCHAR(255);

-- 2. Add user_id to subscriptions (nullable so existing rows can be backfilled).
ALTER TABLE subscriptions
ADD COLUMN user_id UUID REFERENCES users (id);

-- 3. Backfill user_id from the owner of the subscription's workspace.
UPDATE subscriptions s
SET user_id = w.user_id
FROM workspaces w
WHERE s.workspace_id = w.id;

-- 4. user_id is now populated; make it mandatory and index it.
ALTER TABLE subscriptions
ALTER COLUMN user_id SET NOT NULL;

CREATE INDEX idx_subscriptions_user_id ON subscriptions (user_id);

-- 5. Drop the workspace ownership column (drops the FK to workspaces too).
ALTER TABLE subscriptions
DROP COLUMN workspace_id;

-- 6. Move the existing Stripe customer from the user's workspace(s) to the user.
UPDATE users u
SET stripe_customer_id = w.stripe_customer_id
FROM workspaces w
WHERE u.stripe_customer_id IS NULL
AND w.user_id = u.id
AND w.stripe_customer_id IS NOT NULL;

-- 7. Drop the now-unused Stripe customer column from workspaces.
ALTER TABLE workspaces
DROP COLUMN stripe_customer_id;

-- +goose Down
-- 1. Add workspace_id back (nullable so rows can be re-attached).
ALTER TABLE subscriptions
ADD COLUMN workspace_id UUID REFERENCES workspaces (id);

-- 2. Re-attach each subscription to one of the user's workspaces (best effort).
UPDATE subscriptions s
SET workspace_id = w.id
FROM workspaces w
WHERE s.workspace_id IS NULL
AND w.user_id = s.user_id;

-- 3. Restore the NOT NULL constraint and drop the user ownership column.
ALTER TABLE subscriptions
ALTER COLUMN workspace_id SET NOT NULL;

ALTER TABLE subscriptions
DROP COLUMN user_id;

-- 4. Put the Stripe customer back on the workspaces.
ALTER TABLE workspaces
ADD COLUMN stripe_customer_id VARCHAR(255);

UPDATE workspaces w
SET stripe_customer_id = u.stripe_customer_id
FROM users u
WHERE u.id = w.user_id
AND u.stripe_customer_id IS NOT NULL;

-- 5. Drop the Stripe customer column from users.
ALTER TABLE users
DROP COLUMN stripe_customer_id;
28 changes: 14 additions & 14 deletions internal/adapters/postgresql/sqlc/models.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions internal/adapters/postgresql/sqlc/querier.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 14 additions & 16 deletions internal/adapters/postgresql/sqlc/queries.sql
Original file line number Diff line number Diff line change
Expand Up @@ -178,14 +178,14 @@ WHERE (sender_id = $1 AND receiver_id = $2)
ORDER BY created_at DESC LIMIT $3
OFFSET $4;

-- name: UpdateWorkspaceStripeCustomer :one
UPDATE workspaces
-- name: UpdateUserStripeCustomer :one
UPDATE users
SET stripe_customer_id = $2
WHERE id = $1 RETURNING *;

-- name: CreateSubscription :one
INSERT INTO subscriptions (id,
workspace_id,
user_id,
stripe_subscription_id,
stripe_price_id,
status,
Expand Down Expand Up @@ -215,10 +215,9 @@ SET status = 'INACTIVE',
WHERE stripe_subscription_id = $1 RETURNING *;

-- name: GetUserByStripeCustomerID :one
SELECT u.*
FROM users u
JOIN workspaces w ON w.user_id = u.id
WHERE w.stripe_customer_id = $1 LIMIT 1;
SELECT *
FROM users
WHERE stripe_customer_id = $1 LIMIT 1;

-- name: CountUserWorkspaces :one
SELECT COUNT(*)
Expand All @@ -231,17 +230,16 @@ FROM projects
WHERE workspace_id = $1;

-- name: GetUserActiveProSubscription :one
SELECT s.*
FROM subscriptions s
JOIN workspaces w ON w.id = s.workspace_id
WHERE w.user_id = $1
AND s.status = 'ACTIVE'
AND s.plan = 'PRO' LIMIT 1;

-- name: GetWorkspaceActiveSubscription :one
SELECT *
FROM subscriptions
WHERE workspace_id = $1
WHERE user_id = $1
AND status = 'ACTIVE'
AND plan = 'PRO' LIMIT 1;

-- name: GetUserActiveSubscription :one
SELECT *
FROM subscriptions
WHERE user_id = $1
AND status = 'ACTIVE' LIMIT 1;

-- name: CreateIntegrationTask :one
Expand Down
Loading
Loading