From f41ed27bbae9d9b4189a5a413d05893fb8cb4e93 Mon Sep 17 00:00:00 2001 From: "agent-kanban-local[bot]" <292844359+agent-kanban-local[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 05:57:10 -0400 Subject: [PATCH] [codex] separate billing configuration (#479) * feat(admin): separate billing configuration Add dedicated storage egress and downloader credit billing contracts, usecases, RPC wrappers, drawers, generated client updates, and coverage. Agent-Profile: https://agent-kanban.dev/agents/2673e70e0085f4e0 * fix(billing): preserve not found ordering Check storage and downloader existence before quota_store gating in dedicated billing usecases, and cover enabled missing-resource requests at usecase and route levels. Agent-Profile: https://agent-kanban.dev/agents/2673e70e0085f4e0 --------- Co-authored-by: Jordan Park --- cmd/internal/openapi/client.gen.go | 360 ++++++++++++++++++ .../download-tasks.integration.test.ts | 64 ++++ server/http/downloads/downloaders.ts | 21 + server/http/site/storages.cf-test.ts | 23 ++ server/http/site/storages.integration.test.ts | 74 +++- server/http/site/storages.ts | 36 +- server/usecases/downloads/downloads.test.ts | 155 ++++++++ server/usecases/downloads/downloads.ts | 28 +- server/usecases/site/storage.test.ts | 88 ++++- server/usecases/site/storage.ts | 29 +- shared/schemas/downloads.ts | 7 + shared/schemas/index.ts | 6 +- shared/schemas/storage.ts | 7 + spec/download-tasks.feature | 6 + spec/storages.feature | 6 + .../admin/storage-form-drawer.test.tsx | 13 +- src/components/admin/storage-form-drawer.tsx | 91 +---- src/i18n/admin-storages-locale.test.ts | 5 + src/i18n/locales/en.json | 4 + src/i18n/locales/zh.json | 4 + src/lib/api.test.ts | 48 +++ src/lib/api.ts | 10 + .../_authenticated/admin/downloaders.test.tsx | 135 +++++++ .../_authenticated/admin/downloaders.tsx | 175 +++++---- .../admin/storages/index.test.tsx | 63 ++- .../_authenticated/admin/storages/index.tsx | 218 ++++++++++- 26 files changed, 1478 insertions(+), 198 deletions(-) create mode 100644 server/usecases/downloads/downloads.test.ts create mode 100644 src/routes/_authenticated/admin/downloaders.test.tsx diff --git a/cmd/internal/openapi/client.gen.go b/cmd/internal/openapi/client.gen.go index 245c3933..420f4dd6 100644 --- a/cmd/internal/openapi/client.gen.go +++ b/cmd/internal/openapi/client.gen.go @@ -3029,6 +3029,13 @@ type UpdateDownloaderJSONBody struct { RemoteDownloadCreditUnitBytes *int `json:"remoteDownloadCreditUnitBytes,omitempty"` } +// UpdateDownloaderCreditBillingJSONBody defines parameters for UpdateDownloaderCreditBilling. +type UpdateDownloaderCreditBillingJSONBody struct { + CreditsPerUnit int `json:"creditsPerUnit"` + Enabled bool `json:"enabled"` + UnitBytes int `json:"unitBytes"` +} + // ListDownloadTasksParams defines parameters for ListDownloadTasks. type ListDownloadTasksParams struct { Status *ListDownloadTasksParamsStatus `form:"status,omitempty" json:"status,omitempty"` @@ -3538,6 +3545,13 @@ type UpdateStorageJSONBody struct { // UpdateStorageJSONBodyStatus defines parameters for UpdateStorage. type UpdateStorageJSONBodyStatus string +// UpdateStorageEgressBillingJSONBody defines parameters for UpdateStorageEgressBilling. +type UpdateStorageEgressBillingJSONBody struct { + CreditsPerUnit int `json:"creditsPerUnit"` + Enabled bool `json:"enabled"` + UnitBytes int `json:"unitBytes"` +} + // CreateCheckoutJSONBody defines parameters for CreateCheckout. type CreateCheckoutJSONBody struct { PackageId string `json:"packageId"` @@ -3833,6 +3847,9 @@ type RecordDownloaderHeartbeatJSONRequestBody RecordDownloaderHeartbeatJSONBody // UpdateDownloaderJSONRequestBody defines body for UpdateDownloader for application/json ContentType. type UpdateDownloaderJSONRequestBody UpdateDownloaderJSONBody +// UpdateDownloaderCreditBillingJSONRequestBody defines body for UpdateDownloaderCreditBilling for application/json ContentType. +type UpdateDownloaderCreditBillingJSONRequestBody UpdateDownloaderCreditBillingJSONBody + // CreateDownloadTaskJSONRequestBody defines body for CreateDownloadTask for application/json ContentType. type CreateDownloadTaskJSONRequestBody CreateDownloadTaskJSONBody @@ -3914,6 +3931,9 @@ type CreateStorageJSONRequestBody CreateStorageJSONBody // UpdateStorageJSONRequestBody defines body for UpdateStorage for application/json ContentType. type UpdateStorageJSONRequestBody UpdateStorageJSONBody +// UpdateStorageEgressBillingJSONRequestBody defines body for UpdateStorageEgressBilling for application/json ContentType. +type UpdateStorageEgressBillingJSONRequestBody UpdateStorageEgressBillingJSONBody + // CreateCheckoutJSONRequestBody defines body for CreateCheckout for application/json ContentType. type CreateCheckoutJSONRequestBody CreateCheckoutJSONBody @@ -4575,6 +4595,11 @@ type ClientInterface interface { UpdateDownloader(ctx context.Context, id string, body UpdateDownloaderJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // UpdateDownloaderCreditBillingWithBody request with any body + UpdateDownloaderCreditBillingWithBody(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + UpdateDownloaderCreditBilling(ctx context.Context, id string, body UpdateDownloaderCreditBillingJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // ListDownloadTasks request ListDownloadTasks(ctx context.Context, params *ListDownloadTasksParams, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -4866,6 +4891,11 @@ type ClientInterface interface { UpdateStorage(ctx context.Context, id string, body UpdateStorageJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // UpdateStorageEgressBillingWithBody request with any body + UpdateStorageEgressBillingWithBody(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + UpdateStorageEgressBilling(ctx context.Context, id string, body UpdateStorageEgressBillingJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // CreateBillingPortalSession request CreateBillingPortalSession(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -6875,6 +6905,30 @@ func (c *Client) UpdateDownloader(ctx context.Context, id string, body UpdateDow return c.Client.Do(req) } +func (c *Client) UpdateDownloaderCreditBillingWithBody(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewUpdateDownloaderCreditBillingRequestWithBody(c.Server, id, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) UpdateDownloaderCreditBilling(ctx context.Context, id string, body UpdateDownloaderCreditBillingJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewUpdateDownloaderCreditBillingRequest(c.Server, id, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) ListDownloadTasks(ctx context.Context, params *ListDownloadTasksParams, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewListDownloadTasksRequest(c.Server, params) if err != nil { @@ -8147,6 +8201,30 @@ func (c *Client) UpdateStorage(ctx context.Context, id string, body UpdateStorag return c.Client.Do(req) } +func (c *Client) UpdateStorageEgressBillingWithBody(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewUpdateStorageEgressBillingRequestWithBody(c.Server, id, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) UpdateStorageEgressBilling(ctx context.Context, id string, body UpdateStorageEgressBillingJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewUpdateStorageEgressBillingRequest(c.Server, id, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) CreateBillingPortalSession(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewCreateBillingPortalSessionRequest(c.Server) if err != nil { @@ -12618,6 +12696,53 @@ func NewUpdateDownloaderRequestWithBody(server string, id string, contentType st return req, nil } +// NewUpdateDownloaderCreditBillingRequest calls the generic UpdateDownloaderCreditBilling builder with application/json body +func NewUpdateDownloaderCreditBillingRequest(server string, id string, body UpdateDownloaderCreditBillingJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewUpdateDownloaderCreditBillingRequestWithBody(server, id, "application/json", bodyReader) +} + +// NewUpdateDownloaderCreditBillingRequestWithBody generates requests for UpdateDownloaderCreditBilling with any type of body +func NewUpdateDownloaderCreditBillingRequestWithBody(server string, id string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/downloads/downloaders/%s/credit-billing", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPut, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + // NewListDownloadTasksRequest generates requests for ListDownloadTasks func NewListDownloadTasksRequest(server string, params *ListDownloadTasksParams) (*http.Request, error) { var err error @@ -16191,6 +16316,53 @@ func NewUpdateStorageRequestWithBody(server string, id string, contentType strin return req, nil } +// NewUpdateStorageEgressBillingRequest calls the generic UpdateStorageEgressBilling builder with application/json body +func NewUpdateStorageEgressBillingRequest(server string, id string, body UpdateStorageEgressBillingJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewUpdateStorageEgressBillingRequestWithBody(server, id, "application/json", bodyReader) +} + +// NewUpdateStorageEgressBillingRequestWithBody generates requests for UpdateStorageEgressBilling with any type of body +func NewUpdateStorageEgressBillingRequestWithBody(server string, id string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/site/storages/%s/egress-billing", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPut, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + // NewCreateBillingPortalSessionRequest generates requests for CreateBillingPortalSession func NewCreateBillingPortalSessionRequest(server string) (*http.Request, error) { var err error @@ -18121,6 +18293,11 @@ type ClientWithResponsesInterface interface { UpdateDownloaderWithResponse(ctx context.Context, id string, body UpdateDownloaderJSONRequestBody, reqEditors ...RequestEditorFn) (*UpdateDownloaderResponse, error) + // UpdateDownloaderCreditBillingWithBodyWithResponse request with any body + UpdateDownloaderCreditBillingWithBodyWithResponse(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UpdateDownloaderCreditBillingResponse, error) + + UpdateDownloaderCreditBillingWithResponse(ctx context.Context, id string, body UpdateDownloaderCreditBillingJSONRequestBody, reqEditors ...RequestEditorFn) (*UpdateDownloaderCreditBillingResponse, error) + // ListDownloadTasksWithResponse request ListDownloadTasksWithResponse(ctx context.Context, params *ListDownloadTasksParams, reqEditors ...RequestEditorFn) (*ListDownloadTasksResponse, error) @@ -18412,6 +18589,11 @@ type ClientWithResponsesInterface interface { UpdateStorageWithResponse(ctx context.Context, id string, body UpdateStorageJSONRequestBody, reqEditors ...RequestEditorFn) (*UpdateStorageResponse, error) + // UpdateStorageEgressBillingWithBodyWithResponse request with any body + UpdateStorageEgressBillingWithBodyWithResponse(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UpdateStorageEgressBillingResponse, error) + + UpdateStorageEgressBillingWithResponse(ctx context.Context, id string, body UpdateStorageEgressBillingJSONRequestBody, reqEditors ...RequestEditorFn) (*UpdateStorageEgressBillingResponse, error) + // CreateBillingPortalSessionWithResponse request CreateBillingPortalSessionWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*CreateBillingPortalSessionResponse, error) @@ -23408,6 +23590,38 @@ func (r UpdateDownloaderResponse) ContentType() string { return "" } +type UpdateDownloaderCreditBillingResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *Downloader + JSON402 *Error + JSON404 *Error +} + +// Status returns HTTPResponse.Status +func (r UpdateDownloaderCreditBillingResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r UpdateDownloaderCreditBillingResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r UpdateDownloaderCreditBillingResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + type ListDownloadTasksResponse struct { Body []byte HTTPResponse *http.Response @@ -25961,6 +26175,38 @@ func (r UpdateStorageResponse) ContentType() string { return "" } +type UpdateStorageEgressBillingResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *Storage + JSON402 *Error + JSON404 *Error +} + +// Status returns HTTPResponse.Status +func (r UpdateStorageEgressBillingResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r UpdateStorageEgressBillingResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r UpdateStorageEgressBillingResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + type CreateBillingPortalSessionResponse struct { Body []byte HTTPResponse *http.Response @@ -28525,6 +28771,23 @@ func (c *ClientWithResponses) UpdateDownloaderWithResponse(ctx context.Context, return ParseUpdateDownloaderResponse(rsp) } +// UpdateDownloaderCreditBillingWithBodyWithResponse request with arbitrary body returning *UpdateDownloaderCreditBillingResponse +func (c *ClientWithResponses) UpdateDownloaderCreditBillingWithBodyWithResponse(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UpdateDownloaderCreditBillingResponse, error) { + rsp, err := c.UpdateDownloaderCreditBillingWithBody(ctx, id, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseUpdateDownloaderCreditBillingResponse(rsp) +} + +func (c *ClientWithResponses) UpdateDownloaderCreditBillingWithResponse(ctx context.Context, id string, body UpdateDownloaderCreditBillingJSONRequestBody, reqEditors ...RequestEditorFn) (*UpdateDownloaderCreditBillingResponse, error) { + rsp, err := c.UpdateDownloaderCreditBilling(ctx, id, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseUpdateDownloaderCreditBillingResponse(rsp) +} + // ListDownloadTasksWithResponse request returning *ListDownloadTasksResponse func (c *ClientWithResponses) ListDownloadTasksWithResponse(ctx context.Context, params *ListDownloadTasksParams, reqEditors ...RequestEditorFn) (*ListDownloadTasksResponse, error) { rsp, err := c.ListDownloadTasks(ctx, params, reqEditors...) @@ -29452,6 +29715,23 @@ func (c *ClientWithResponses) UpdateStorageWithResponse(ctx context.Context, id return ParseUpdateStorageResponse(rsp) } +// UpdateStorageEgressBillingWithBodyWithResponse request with arbitrary body returning *UpdateStorageEgressBillingResponse +func (c *ClientWithResponses) UpdateStorageEgressBillingWithBodyWithResponse(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UpdateStorageEgressBillingResponse, error) { + rsp, err := c.UpdateStorageEgressBillingWithBody(ctx, id, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseUpdateStorageEgressBillingResponse(rsp) +} + +func (c *ClientWithResponses) UpdateStorageEgressBillingWithResponse(ctx context.Context, id string, body UpdateStorageEgressBillingJSONRequestBody, reqEditors ...RequestEditorFn) (*UpdateStorageEgressBillingResponse, error) { + rsp, err := c.UpdateStorageEgressBilling(ctx, id, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseUpdateStorageEgressBillingResponse(rsp) +} + // CreateBillingPortalSessionWithResponse request returning *CreateBillingPortalSessionResponse func (c *ClientWithResponses) CreateBillingPortalSessionWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*CreateBillingPortalSessionResponse, error) { rsp, err := c.CreateBillingPortalSession(ctx, reqEditors...) @@ -37375,6 +37655,46 @@ func ParseUpdateDownloaderResponse(rsp *http.Response) (*UpdateDownloaderRespons return response, nil } +// ParseUpdateDownloaderCreditBillingResponse parses an HTTP response from a UpdateDownloaderCreditBillingWithResponse call +func ParseUpdateDownloaderCreditBillingResponse(rsp *http.Response) (*UpdateDownloaderCreditBillingResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &UpdateDownloaderCreditBillingResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest Downloader + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 402: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON402 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + } + + return response, nil +} + // ParseListDownloadTasksResponse parses an HTTP response from a ListDownloadTasksWithResponse call func ParseListDownloadTasksResponse(rsp *http.Response) (*ListDownloadTasksResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -40341,6 +40661,46 @@ func ParseUpdateStorageResponse(rsp *http.Response) (*UpdateStorageResponse, err return response, nil } +// ParseUpdateStorageEgressBillingResponse parses an HTTP response from a UpdateStorageEgressBillingWithResponse call +func ParseUpdateStorageEgressBillingResponse(rsp *http.Response) (*UpdateStorageEgressBillingResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &UpdateStorageEgressBillingResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest Storage + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 402: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON402 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + } + + return response, nil +} + // ParseCreateBillingPortalSessionResponse parses an HTTP response from a CreateBillingPortalSessionWithResponse call func ParseCreateBillingPortalSessionResponse(rsp *http.Response) (*CreateBillingPortalSessionResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) diff --git a/server/http/downloads/download-tasks.integration.test.ts b/server/http/downloads/download-tasks.integration.test.ts index 4e0ca511..c7bf67d4 100644 --- a/server/http/downloads/download-tasks.integration.test.ts +++ b/server/http/downloads/download-tasks.integration.test.ts @@ -1875,4 +1875,68 @@ describe('Downloaders — free plan limit', () => { expect((await postDownloader(app, admin, 'first')).status).toBe(201) expect((await postDownloader(app, admin, 'second')).status).toBe(201) }) + + it('updates downloader credit billing through the dedicated route [spec: downloaders/credit-billing]', async () => { + const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) + await seedBusinessLicense(db) + const admin = await adminHeaders(app) + const createRes = await postDownloader(app, admin, 'billable') + const created = (await createRes.json()) as { downloader: { id: string } } + + const res = await app.request(`/api/downloads/downloaders/${created.downloader.id}/credit-billing`, { + method: 'PUT', + headers: { ...admin, 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: true, unitBytes: 2048, creditsPerUnit: 3 }), + }) + expect(res.status).toBe(200) + const body = (await res.json()) as Downloader + expect(body.remoteDownloadCreditBillingEnabled).toBe(true) + expect(body.remoteDownloadCreditUnitBytes).toBe(2048) + expect(body.remoteDownloadCreditPerUnit).toBe(3) + }) + + it('returns 402 when enabling downloader credit billing without quota_store', async () => { + const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) + const admin = await adminHeaders(app) + const createRes = await postDownloader(app, admin, 'blocked-billing') + const created = (await createRes.json()) as { downloader: { id: string } } + + const res = await app.request(`/api/downloads/downloaders/${created.downloader.id}/credit-billing`, { + method: 'PUT', + headers: { ...admin, 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: true, unitBytes: 2048, creditsPerUnit: 3 }), + }) + expect(res.status).toBe(402) + const body = (await res.json()) as { + error: { message: string; details: { reason: string; metadata: Record }[] } + } + expect(body.error.message).toBe('Feature not available') + expect(body.error.details[0].reason).toBe('FEATURE_NOT_AVAILABLE') + expect(body.error.details[0].metadata.feature).toBe('quota_store') + }) + + it('returns 404 from downloader credit billing when disabled for a missing downloader', async () => { + const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) + const admin = await adminHeaders(app) + + const res = await app.request('/api/downloads/downloaders/missing/credit-billing', { + method: 'PUT', + headers: { ...admin, 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: false, unitBytes: 2048, creditsPerUnit: 3 }), + }) + expect(res.status).toBe(404) + }) + + it('returns 404 from downloader credit billing when enabled for a missing downloader without quota_store', async () => { + const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) + await seedProLicense(db) + const admin = await adminHeaders(app) + + const res = await app.request('/api/downloads/downloaders/missing/credit-billing', { + method: 'PUT', + headers: { ...admin, 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: true, unitBytes: 2048, creditsPerUnit: 3 }), + }) + expect(res.status).toBe(404) + }) }) diff --git a/server/http/downloads/downloaders.ts b/server/http/downloads/downloaders.ts index 8b6b4d20..1e5751ff 100644 --- a/server/http/downloads/downloaders.ts +++ b/server/http/downloads/downloaders.ts @@ -5,6 +5,7 @@ import { downloaderHeartbeatSchema, downloaderSchema, pageSchema, + updateDownloaderCreditBillingSchema, updateDownloaderSchema, } from '@shared/schemas' import { FREE_DOWNLOADER_LIMIT } from '../../../shared/constants' @@ -17,6 +18,7 @@ import { listDownloaders, recordDownloaderHeartbeat, updateDownloader, + updateDownloaderCreditBilling, } from '../../usecases/downloads/downloads' import { featureBlocked, unauthorized } from '../../usecases/ports' import { loadBindingState } from '../../usecases/site/licensing' @@ -67,6 +69,21 @@ const updateRoute = createRoute({ }, }) +const updateCreditBillingRoute = createRoute({ + operationId: 'updateDownloaderCreditBilling', + summary: 'Update downloader credit billing', + tags: ['Downloaders'], + method: 'put', + path: '/{id}/credit-billing', + middleware: [requireAdmin] as const, + request: { params: z.object({ id: z.string() }), ...jsonBody(updateDownloaderCreditBillingSchema) }, + responses: { + 200: jsonContent(downloaderSchema, 'Updated downloader'), + 402: errorResponse('Feature not available'), + 404: errorResponse('Not found'), + }, +}) + const deleteRoute = createRoute({ operationId: 'deleteDownloader', summary: 'Delete downloader', @@ -134,6 +151,10 @@ const downloadersRoute = new OpenAPIHono() } return c.json(await updateDownloader(c.get('deps'), id, input), 200) }) + .openapi(updateCreditBillingRoute, async (c) => { + const { id } = c.req.valid('param') + return c.json(await updateDownloaderCreditBilling(c.get('deps'), id, c.req.valid('json')), 200) + }) .openapi(deleteRoute, async (c) => { const { id } = c.req.valid('param') await deleteDownloader(c.get('deps'), id) diff --git a/server/http/site/storages.cf-test.ts b/server/http/site/storages.cf-test.ts index 09db012c..454cd730 100644 --- a/server/http/site/storages.cf-test.ts +++ b/server/http/site/storages.cf-test.ts @@ -152,6 +152,29 @@ describe('[CF] Admin Storages API', () => { expect(body.title).toBe('Updated CF S3') }) + it('PUT /api/site/storages/:id/egress-billing enforces quota_store for enabling', async () => { + const app = await buildApp() + const headers = await adminHeaders(app) + const platform = createCloudflarePlatform(env) + const created = await createStorageRepo(platform.db).create({ + ...validStorage, + title: `CF Egress Billing ${Date.now()}`, + bucket: `cf-egress-billing-${Date.now()}`, + }) + + const res = await app.request(`/api/site/storages/${created.id}/egress-billing`, { + method: 'PUT', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: true, unitBytes: 1024, creditsPerUnit: 2 }), + }) + expect(res.status).toBe(402) + const body = (await res.json()) as { + error: { details: Array<{ reason: string; metadata: Record }> } + } + expect(body.error.details[0].reason).toBe('FEATURE_NOT_AVAILABLE') + expect(body.error.details[0].metadata.feature).toBe('quota_store') + }) + it('DELETE /api/site/storages/:id deletes a storage', async () => { const app = await buildApp() const headers = await adminHeaders(app) diff --git a/server/http/site/storages.integration.test.ts b/server/http/site/storages.integration.test.ts index 8b3395e4..4da60df1 100644 --- a/server/http/site/storages.integration.test.ts +++ b/server/http/site/storages.integration.test.ts @@ -2,7 +2,7 @@ import { FREE_STORAGE_LIMIT } from '@shared/constants' import { sql } from 'drizzle-orm' import { describe, expect, it } from 'vitest' import { createStorageRepo } from '../../adapters/repos/storage.js' -import { adminHeaders, authedHeaders, createTestApp } from '../../test/setup.js' +import { adminHeaders, authedHeaders, createTestApp, seedBusinessLicense, seedProLicense } from '../../test/setup.js' const validStorage = { title: 'Test S3', @@ -189,6 +189,78 @@ describe('Admin Storages API', () => { expect(res.status).toBe(404) }) + it('PUT /:id/egress-billing updates storage credits billing [spec: storages/egress-billing]', async () => { + const { app, db } = await createTestApp() + await seedBusinessLicense(db) + const headers = await adminHeaders(app) + + const createRes = await app.request('/api/site/storages', { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify(validStorage), + }) + const created = (await createRes.json()) as { id: string } + + const res = await app.request(`/api/site/storages/${created.id}/egress-billing`, { + method: 'PUT', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: true, unitBytes: 1024, creditsPerUnit: 2 }), + }) + expect(res.status).toBe(200) + const body = (await res.json()) as Record + expect(body.egressCreditBillingEnabled).toBe(true) + expect(body.egressCreditUnitBytes).toBe(1024) + expect(body.egressCreditPerUnit).toBe(2) + }) + + it('PUT /:id/egress-billing returns 402 when quota_store is unavailable', async () => { + const { app } = await createTestApp() + const headers = await adminHeaders(app) + + const createRes = await app.request('/api/site/storages', { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify(validStorage), + }) + const created = (await createRes.json()) as { id: string } + + const res = await app.request(`/api/site/storages/${created.id}/egress-billing`, { + method: 'PUT', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: true, unitBytes: 1024, creditsPerUnit: 2 }), + }) + expect(res.status).toBe(402) + const body = (await res.json()) as { + error: { message: string; details: Array<{ reason: string; metadata: Record }> } + } + expect(body.error.message).toBe('Feature not available') + expect(body.error.details[0].reason).toBe('FEATURE_NOT_AVAILABLE') + expect(body.error.details[0].metadata.feature).toBe('quota_store') + }) + + it('PUT /:id/egress-billing returns 404 for missing storage when disabled', async () => { + const { app } = await createTestApp() + const headers = await adminHeaders(app) + const res = await app.request('/api/site/storages/nonexistent/egress-billing', { + method: 'PUT', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: false, unitBytes: 1024, creditsPerUnit: 2 }), + }) + expect(res.status).toBe(404) + }) + + it('PUT /:id/egress-billing returns 404 for missing storage when enabled without quota_store', async () => { + const { app, db } = await createTestApp() + await seedProLicense(db) + const headers = await adminHeaders(app) + const res = await app.request('/api/site/storages/nonexistent/egress-billing', { + method: 'PUT', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: true, unitBytes: 1024, creditsPerUnit: 2 }), + }) + expect(res.status).toBe(404) + }) + it('DELETE /:id deletes a storage [spec: storages/delete]', async () => { const { app } = await createTestApp() const headers = await adminHeaders(app) diff --git a/server/http/site/storages.ts b/server/http/site/storages.ts index c6c0aab2..ed267127 100644 --- a/server/http/site/storages.ts +++ b/server/http/site/storages.ts @@ -1,9 +1,16 @@ import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi' -import { createStorageSchema, pageSchema, updateStorageSchema } from '@shared/schemas' +import { createStorageSchema, pageSchema, updateStorageEgressBillingSchema, updateStorageSchema } from '@shared/schemas' import { requireAdmin } from '../../middleware/auth' import type { Env } from '../../middleware/platform' import { type StorageRecord, storageNotFound } from '../../usecases/ports' -import { createStorage, deleteStorage, getStorage, listStorages, updateStorage } from '../../usecases/site/storage' +import { + createStorage, + deleteStorage, + getStorage, + listStorages, + updateStorage, + updateStorageEgressBilling, +} from '../../usecases/site/storage' import { errorResponse, jsonBody, jsonContent } from '../openapi' // Admin storage config. The response intentionally includes the S3 credentials @@ -93,6 +100,21 @@ const updateStorageRoute = createRoute({ }, }) +const updateStorageEgressBillingRoute = createRoute({ + operationId: 'updateStorageEgressBilling', + summary: 'Update storage egress billing', + tags: ['Storages'], + method: 'put', + path: '/{id}/egress-billing', + middleware: [requireAdmin] as const, + request: { params: z.object({ id: z.string() }), ...jsonBody(updateStorageEgressBillingSchema) }, + responses: { + 200: jsonContent(storageSchema, 'Updated storage'), + 402: errorResponse('Feature not available'), + 404: errorResponse('Storage not found'), + }, +}) + const deleteStorageRoute = createRoute({ operationId: 'deleteStorage', summary: 'Delete storage', @@ -138,6 +160,16 @@ const storages = new OpenAPIHono() if (!result.ok) throw result.error return c.json(toStorageDTO(result.storage), 200) }) + .openapi(updateStorageEgressBillingRoute, async (c) => { + const result = await updateStorageEgressBilling(c.get('deps'), { + userId: c.get('userId')!, + orgId: c.get('orgId')!, + id: c.req.valid('param').id, + input: c.req.valid('json'), + }) + if (!result.ok) throw result.error + return c.json(toStorageDTO(result.storage), 200) + }) .openapi(deleteStorageRoute, async (c) => { const id = c.req.valid('param').id const result = await deleteStorage(c.get('deps'), { userId: c.get('userId')!, orgId: c.get('orgId')!, id }) diff --git a/server/usecases/downloads/downloads.test.ts b/server/usecases/downloads/downloads.test.ts new file mode 100644 index 00000000..6a2f76fd --- /dev/null +++ b/server/usecases/downloads/downloads.test.ts @@ -0,0 +1,155 @@ +import type { BindingState, Downloader } from '@shared/types' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { DownloaderRecord, DownloaderRepo } from '../ports' +import { type AppError, DownloadError } from '../ports' +import { loadBindingState } from '../site/licensing' +import { type DownloadsDeps, updateDownloaderCreditBilling } from './downloads' + +vi.mock('../site/licensing', () => ({ loadBindingState: vi.fn() })) + +const PRO: BindingState = { bound: true, active: true, edition: 'pro' } +const BUSINESS: BindingState = { bound: true, active: true, edition: 'business' } + +const downloader: Downloader = { + id: 'downloader-1', + name: 'Edge worker', + status: 'offline', + enabled: true, + version: '1.0.0', + hostname: 'edge-1', + platform: 'linux', + arch: 'amd64', + engine: 'aria2', + capabilities: ['http'], + maxConcurrentTasks: 2, + currentTasks: 0, + downloadBps: 0, + uploadBps: 0, + freeDiskBytes: 1024, + remoteDownloadCreditBillingEnabled: true, + remoteDownloadCreditUnitBytes: 1024, + remoteDownloadCreditPerUnit: 2, + lastHeartbeatAt: null, + createdBy: 'user-1', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', +} + +const downloaderRecord = { + ...downloader, + tokenHash: 'hash', + tokenJti: 'jti', + lastHeartbeatAt: null, + createdAt: new Date(downloader.createdAt), + updatedAt: new Date(downloader.updatedAt), +} satisfies DownloaderRecord + +function makeDeps(downloaders: Partial = {}) { + const update = vi.fn(async () => {}) + const repo: DownloaderRepo = { + insert: async () => {}, + list: async () => [], + get: async () => downloader, + getRecord: async () => downloaderRecord, + findRecord: async () => downloaderRecord, + update, + recordHeartbeat: async () => {}, + delete: async () => {}, + listAssignmentCandidates: async () => [], + listStaleIds: async () => [], + listUnreachableIds: async () => [], + markStaleOffline: async () => {}, + ...downloaders, + } + return { + deps: { + downloaders: repo, + downloadTasks: {}, + downloadTokens: {}, + licenseBinding: {}, + licensingCloud: {}, + remoteDownloadUsage: {}, + } as DownloadsDeps, + update, + } +} + +beforeEach(() => vi.clearAllMocks()) + +describe('updateDownloaderCreditBilling', () => { + it('updates credit billing fields through the downloader repo', async () => { + vi.mocked(loadBindingState).mockResolvedValue(BUSINESS) + const { deps, update } = makeDeps() + + const out = await updateDownloaderCreditBilling(deps, 'downloader-1', { + enabled: true, + unitBytes: 2048, + creditsPerUnit: 3, + }) + + expect(out).toBe(downloader) + expect(update).toHaveBeenCalledWith( + 'downloader-1', + { + remoteDownloadCreditBillingEnabled: true, + remoteDownloadCreditUnitBytes: 2048, + remoteDownloadCreditPerUnit: 3, + }, + expect.any(Date), + ) + }) + + it('blocks enabling credit billing when quota_store is unavailable', async () => { + vi.mocked(loadBindingState).mockResolvedValue(PRO) + const { deps, update } = makeDeps() + + await expect( + updateDownloaderCreditBilling(deps, 'downloader-1', { + enabled: true, + unitBytes: 2048, + creditsPerUnit: 3, + }), + ).rejects.toMatchObject({ + name: 'AppError', + httpStatus: 402, + meta: { reason: 'FEATURE_NOT_AVAILABLE', metadata: { feature: 'quota_store' } }, + } satisfies Partial) + expect(update).not.toHaveBeenCalled() + }) + + it('preserves not_found when credit billing is disabled for a missing downloader', async () => { + vi.mocked(loadBindingState).mockResolvedValue(PRO) + const { deps, update } = makeDeps({ + getRecord: async () => { + throw new DownloadError('not_found') + }, + }) + + await expect( + updateDownloaderCreditBilling(deps, 'missing', { + enabled: false, + unitBytes: 2048, + creditsPerUnit: 3, + }), + ).rejects.toMatchObject({ name: 'DownloadError', code: 'not_found' }) + expect(update).not.toHaveBeenCalled() + }) + + it('preserves not_found before quota_store gating for a missing downloader when credit billing is enabled', async () => { + vi.mocked(loadBindingState).mockResolvedValue(PRO) + const { deps, update } = makeDeps({ + getRecord: async () => { + throw new DownloadError('not_found') + }, + }) + + await expect( + updateDownloaderCreditBilling(deps, 'missing', { + enabled: true, + unitBytes: 2048, + creditsPerUnit: 3, + }), + ).rejects.toMatchObject({ name: 'DownloadError', code: 'not_found' }) + expect(update).not.toHaveBeenCalled() + }) +}) diff --git a/server/usecases/downloads/downloads.ts b/server/usecases/downloads/downloads.ts index 04c09aae..a33c4568 100644 --- a/server/usecases/downloads/downloads.ts +++ b/server/usecases/downloads/downloads.ts @@ -3,6 +3,7 @@ import type { CreateDownloadTaskInput, DownloaderHeartbeatInput, DownloadTaskActionInput, + UpdateDownloaderCreditBillingInput, UpdateDownloaderInput, UpdateDownloadTaskInput, } from '@shared/schemas' @@ -10,6 +11,7 @@ import { downloadTaskRuntimeSchema } from '@shared/schemas' import type { Downloader, DownloadTask, DownloadTaskRuntime } from '@shared/types' import { nanoid } from 'nanoid' import { ZPAN_CLOUD_URL_DEFAULT } from '../../../shared/constants' +import { hasFeature } from '../../domain/licensing' import type { Platform } from '../../platform/interface' import type { DownloaderRecord, @@ -22,7 +24,8 @@ import type { ListDownloadTasksFilters, RemoteDownloadUsageRepo, } from '../ports' -import { DownloadError } from '../ports' +import { DownloadError, featureBlocked } from '../ports' +import { loadBindingState } from '../site/licensing' import { RemoteDownloadBillingBlockedError, reportRemoteDownloadUnit } from './remote-download-usage' // Pure orchestration over the downloader / download-task repos: registration, @@ -142,6 +145,29 @@ export async function updateDownloader( return deps.downloaders.get(id) } +export async function updateDownloaderCreditBilling( + deps: DownloadsDeps, + id: string, + input: UpdateDownloaderCreditBillingInput, +): Promise { + await deps.downloaders.getRecord(id) // throws not_found + if (input.enabled && !hasFeature('quota_store', await loadBindingState(deps))) { + throw featureBlocked('Feature not available', { + metadata: { feature: 'quota_store' }, + }) + } + await deps.downloaders.update( + id, + { + remoteDownloadCreditBillingEnabled: input.enabled, + remoteDownloadCreditUnitBytes: input.unitBytes, + remoteDownloadCreditPerUnit: input.creditsPerUnit, + }, + new Date(), + ) + return deps.downloaders.get(id) +} + export async function deleteDownloader(deps: DownloadsDeps, id: string): Promise<{ id: string; deleted: true }> { await deps.downloaders.getRecord(id) // throws not_found const now = new Date() diff --git a/server/usecases/site/storage.test.ts b/server/usecases/site/storage.test.ts index b496166e..8bc0d0b2 100644 --- a/server/usecases/site/storage.test.ts +++ b/server/usecases/site/storage.test.ts @@ -5,7 +5,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ActivityRepo, LicenseBindingRepo, StorageRecord, StorageRepo } from '../ports' import { AppError } from '../ports' import { loadBindingState } from './licensing' -import { createStorage, deleteStorage, getStorage, listStorages, type StorageDeps, updateStorage } from './storage' +import { + createStorage, + deleteStorage, + getStorage, + listStorages, + type StorageDeps, + updateStorage, + updateStorageEgressBilling, +} from './storage' // loadBindingState derives features from a signed certificate — out of scope for // a usecase unit test. Mock it so each case feeds a chosen edition; the real @@ -184,6 +192,84 @@ describe('storage usecase', () => { }) }) + describe('updateStorageEgressBilling', () => { + it('updates egress billing fields and records activity', async () => { + edition(BUSINESS) + const update = vi.fn(async () => sampleStorage) + const { deps, record } = makeDeps({ get: async () => sampleStorage, update }) + const out = await updateStorageEgressBilling(deps, { + userId: 'u1', + orgId: 'o1', + id: 'st-1', + input: { enabled: true, unitBytes: 1024, creditsPerUnit: 2 }, + }) + expect(out).toEqual({ ok: true, storage: sampleStorage }) + expect(update).toHaveBeenCalledWith('st-1', { + egressCreditBillingEnabled: true, + egressCreditUnitBytes: 1024, + egressCreditPerUnit: 2, + }) + expect(record).toHaveBeenCalledWith(expect.objectContaining({ action: 'storage_update', targetId: 'st-1' })) + }) + + it('blocks enabling egress billing without quota_store', async () => { + edition(PRO) + const update = vi.fn(async () => sampleStorage) + const { deps, record } = makeDeps({ get: async () => sampleStorage, update }) + const out = await updateStorageEgressBilling(deps, { + userId: 'u1', + orgId: 'o1', + id: 'st-1', + input: { enabled: true, unitBytes: 1024, creditsPerUnit: 2 }, + }) + expect(out.ok).toBe(false) + if (!out.ok) { + expect(out.error).toBeInstanceOf(AppError) + expect(out.error.httpStatus).toBe(402) + expect(out.error.meta.reason).toBe('FEATURE_NOT_AVAILABLE') + expect(out.error.meta.metadata).toEqual({ feature: 'quota_store' }) + } + expect(update).not.toHaveBeenCalled() + expect(record).not.toHaveBeenCalled() + }) + + it('returns not_found for a missing storage when billing is disabled', async () => { + edition(PRO) + const { deps, record } = makeDeps({ update: async () => null }) + const out = await updateStorageEgressBilling(deps, { + userId: 'u1', + orgId: 'o1', + id: 'missing', + input: { enabled: false, unitBytes: 1024, creditsPerUnit: 2 }, + }) + expect(out.ok).toBe(false) + if (!out.ok) { + expect(out.error.httpStatus).toBe(404) + expect(out.error.message).toBe('Storage not found') + } + expect(record).not.toHaveBeenCalled() + }) + + it('returns not_found before quota_store gating for a missing storage when billing is enabled', async () => { + edition(PRO) + const update = vi.fn(async () => null) + const { deps, record } = makeDeps({ get: async () => null, update }) + const out = await updateStorageEgressBilling(deps, { + userId: 'u1', + orgId: 'o1', + id: 'missing', + input: { enabled: true, unitBytes: 1024, creditsPerUnit: 2 }, + }) + expect(out.ok).toBe(false) + if (!out.ok) { + expect(out.error.httpStatus).toBe(404) + expect(out.error.message).toBe('Storage not found') + } + expect(update).not.toHaveBeenCalled() + expect(record).not.toHaveBeenCalled() + }) + }) + describe('deleteStorage', () => { it('deletes and records activity with the storage name', async () => { const del = vi.fn(async () => 'ok' as const) diff --git a/server/usecases/site/storage.ts b/server/usecases/site/storage.ts index fe04d631..99f1b01a 100644 --- a/server/usecases/site/storage.ts +++ b/server/usecases/site/storage.ts @@ -9,7 +9,7 @@ // the CRUD resource; that one is a cross-resource operation. import { FREE_STORAGE_LIMIT } from '@shared/constants' -import type { CreateStorageInput, UpdateStorageInput } from '@shared/schemas' +import type { CreateStorageInput, UpdateStorageEgressBillingInput, UpdateStorageInput } from '@shared/schemas' import { hasFeature } from '../../domain/licensing' import { type ActivityRepo, @@ -121,6 +121,33 @@ export async function updateStorage( return { ok: true, storage } } +export async function updateStorageEgressBilling( + deps: StorageDeps, + params: { userId: string; orgId: string; id: string; input: UpdateStorageEgressBillingInput }, +): Promise { + const { userId, orgId, id, input } = params + const existing = await deps.storages.get(id) + if (!existing) return { ok: false, error: storageNotFound() } + if (input.enabled && !hasFeature('quota_store', await loadBindingState({ licenseBinding: deps.licenseBinding }))) { + return { ok: false, error: featureBlockError({ feature: 'quota_store' }) } + } + const storage = await deps.storages.update(id, { + egressCreditBillingEnabled: input.enabled, + egressCreditUnitBytes: input.unitBytes, + egressCreditPerUnit: input.creditsPerUnit, + }) + if (!storage) return { ok: false, error: storageNotFound() } + await deps.activity.record({ + orgId, + userId, + action: 'storage_update', + targetType: 'storage', + targetId: storage.id, + targetName: storage.title, + }) + return { ok: true, storage } +} + export async function deleteStorage( deps: StorageDeps, params: { userId: string; orgId: string; id: string }, diff --git a/shared/schemas/downloads.ts b/shared/schemas/downloads.ts index 13b484f3..46c8b4ad 100644 --- a/shared/schemas/downloads.ts +++ b/shared/schemas/downloads.ts @@ -233,6 +233,12 @@ export const updateDownloaderSchema = z.object({ remoteDownloadCreditPerUnit: z.number().int().positive().optional(), }) +export const updateDownloaderCreditBillingSchema = z.object({ + enabled: z.boolean(), + unitBytes: z.number().int().positive(), + creditsPerUnit: z.number().int().positive(), +}) + export const createDownloaderSchema = z.object({ name: z.string().min(1).max(120), heartbeat: downloaderHeartbeatSchema, @@ -341,6 +347,7 @@ export const completeObjectUploadSchema = z.object({ export type DownloaderHeartbeatInput = z.infer export type UpdateDownloaderInput = z.infer +export type UpdateDownloaderCreditBillingInput = z.infer export type CreateDownloaderInput = z.infer export type CreateDownloadTaskInput = z.infer export type UpdateDownloadTaskInput = z.infer diff --git a/shared/schemas/index.ts b/shared/schemas/index.ts index 592227b4..c244cb86 100644 --- a/shared/schemas/index.ts +++ b/shared/schemas/index.ts @@ -83,6 +83,7 @@ export type { DownloadTaskSchema, ListDownloadTasksQuery, PresignObjectUploadPartsInput, + UpdateDownloaderCreditBillingInput, UpdateDownloaderInput, UpdateDownloadTaskInput, } from './downloads' @@ -108,6 +109,7 @@ export { downloadTaskStatusUpdateSchema, listDownloadTasksQuerySchema, presignObjectUploadPartsSchema, + updateDownloaderCreditBillingSchema, updateDownloaderSchema, updateDownloadTaskSchema, } from './downloads' @@ -133,8 +135,8 @@ export { shareKindSchema, shareRecipientSchema, } from './share' -export type { CreateStorageInput, UpdateStorageInput } from './storage' -export { createStorageSchema, updateStorageSchema } from './storage' +export type { CreateStorageInput, UpdateStorageEgressBillingInput, UpdateStorageInput } from './storage' +export { createStorageSchema, updateStorageEgressBillingSchema, updateStorageSchema } from './storage' export const signInSchema = z.object({ email: z.string().email(), diff --git a/shared/schemas/storage.ts b/shared/schemas/storage.ts index e0eeb0c2..0947e027 100644 --- a/shared/schemas/storage.ts +++ b/shared/schemas/storage.ts @@ -31,5 +31,12 @@ export const updateStorageSchema = z.object({ status: z.enum(['active', 'disabled']).optional(), }) +export const updateStorageEgressBillingSchema = z.object({ + enabled: z.boolean(), + unitBytes: z.number().int().positive(), + creditsPerUnit: z.number().int().positive(), +}) + export type CreateStorageInput = z.input export type UpdateStorageInput = z.input +export type UpdateStorageEgressBillingInput = z.input diff --git a/spec/download-tasks.feature b/spec/download-tasks.feature index 1d848148..e4f72b09 100644 --- a/spec/download-tasks.feature +++ b/spec/download-tasks.feature @@ -164,3 +164,9 @@ Feature: Remote download tasks Given the downloaders_unlimited entitlement When additional downloaders register Then they are allowed + + @downloaders/credit-billing @api + Scenario: Admins configure downloader credits separately + Given an existing downloader + When an admin updates downloader credit billing + Then the billing fields are persisted through the dedicated route diff --git a/spec/storages.feature b/spec/storages.feature index aa91f7be..0ed392d8 100644 --- a/spec/storages.feature +++ b/spec/storages.feature @@ -50,6 +50,12 @@ Feature: Storages When an admin updates its fields Then the changes are persisted + @storages/egress-billing @api + Scenario: Admins configure storage egress credits separately + Given an existing storage + When an admin updates egress credits billing + Then the billing fields are persisted through the dedicated route + @storages/delete @api Scenario: Admins delete an unused storage Given an existing storage referenced by no files diff --git a/src/components/admin/storage-form-drawer.test.tsx b/src/components/admin/storage-form-drawer.test.tsx index 078bfd54..f7da4b49 100644 --- a/src/components/admin/storage-form-drawer.test.tsx +++ b/src/components/admin/storage-form-drawer.test.tsx @@ -61,7 +61,7 @@ function renderStorageFormDrawer(props: Partial - undefined} storage={null} hasTrafficBilling={false} {...props} /> + undefined} storage={null} {...props} /> , ) } @@ -100,18 +100,17 @@ describe('StorageFormDrawer', () => { accessKey: 'new-access', secretKey: 'new-secret', capacity: 2 * 1024 * 1024 * 1024, - egressCreditBillingEnabled: false, - egressCreditUnitBytes: 100 * 1024 * 1024, }), ), ) + expect(createStorage).not.toHaveBeenCalledWith(expect.objectContaining({ egressCreditBillingEnabled: false })) expect(onOpenChange).toHaveBeenCalledWith(false) }) it('resets edit values, submits update payload, and toggles secret visibility', async () => { vi.stubGlobal('ResizeObserver', TestResizeObserver) vi.mocked(updateStorage).mockResolvedValue(storage) - renderStorageFormDrawer({ storage, hasTrafficBilling: true }) + renderStorageFormDrawer({ storage }) const secretInput = screen.getByLabelText('admin.storages.fieldSecretKey') as HTMLInputElement expect(secretInput.getAttribute('type')).toBe('password') @@ -119,7 +118,7 @@ describe('StorageFormDrawer', () => { expect(secretInput.getAttribute('type')).toBe('text') expect(screen.getByRole('button', { name: 'admin.storages.hideSecretKey' })).toBeTruthy() expect((screen.getByLabelText('admin.storages.fieldCapacity') as HTMLInputElement).value).toBe('2') - expect((screen.getByLabelText('admin.storages.egressBillingUnit') as HTMLInputElement).value).toBe('100') + expect(screen.queryByLabelText('admin.storages.egressBillingUnit')).toBeNull() fireEvent.click(screen.getByRole('button', { name: 'common.save' })) await waitFor(() => @@ -128,11 +127,9 @@ describe('StorageFormDrawer', () => { expect.objectContaining({ title: 'Primary storage', capacity: 2 * 1024 * 1024 * 1024, - egressCreditBillingEnabled: true, - egressCreditUnitBytes: 100 * 1024 * 1024, - egressCreditPerUnit: 3, }), ), ) + expect(updateStorage).not.toHaveBeenCalledWith('storage-1', expect.objectContaining({ egressCreditPerUnit: 3 })) }) }) diff --git a/src/components/admin/storage-form-drawer.tsx b/src/components/admin/storage-form-drawer.tsx index b3c50460..22fa0eb8 100644 --- a/src/components/admin/storage-form-drawer.tsx +++ b/src/components/admin/storage-form-drawer.tsx @@ -36,10 +36,6 @@ const storageFormSchema = z.object({ customHost: z.string().optional(), capacityValue: z.coerce.number().min(0), capacityUnit: z.enum(['MB', 'GB', 'TB']), - egressCreditBillingEnabled: z.boolean(), - egressCreditUnitValue: z.coerce.number().min(1), - egressCreditUnit: z.enum(['MB', 'GB', 'TB']), - egressCreditPerUnit: z.coerce.number().int().min(1), forcePathStyle: z.boolean(), }) @@ -55,10 +51,6 @@ const DEFAULT_VALUES: StorageFormValues = { customHost: '', capacityValue: 0, capacityUnit: 'GB', - egressCreditBillingEnabled: false, - egressCreditUnitValue: 100, - egressCreditUnit: 'MB', - egressCreditPerUnit: 1, forcePathStyle: true, } @@ -66,10 +58,9 @@ interface StorageFormDrawerProps { open: boolean onOpenChange: (open: boolean) => void storage: Storage | null - hasTrafficBilling: boolean } -export function StorageFormDrawer({ open, onOpenChange, storage, hasTrafficBilling }: StorageFormDrawerProps) { +export function StorageFormDrawer({ open, onOpenChange, storage }: StorageFormDrawerProps) { const { t } = useTranslation() const queryClient = useQueryClient() const [showSecret, setShowSecret] = useState(false) @@ -84,7 +75,6 @@ export function StorageFormDrawer({ open, onOpenChange, storage, hasTrafficBilli if (!open) return if (storage) { const { value, unit } = bytesToDisplay(storage.capacity ?? 0) - const egressUnit = bytesToDisplay(storage.egressCreditUnitBytes ?? UNITS.MB * 100) form.reset({ title: storage.title, bucket: storage.bucket, @@ -95,33 +85,20 @@ export function StorageFormDrawer({ open, onOpenChange, storage, hasTrafficBilli customHost: storage.customHost || '', capacityValue: value, capacityUnit: unit, - egressCreditBillingEnabled: hasTrafficBilling ? (storage.egressCreditBillingEnabled ?? false) : false, - egressCreditUnitValue: egressUnit.value, - egressCreditUnit: egressUnit.unit, - egressCreditPerUnit: storage.egressCreditPerUnit ?? 1, forcePathStyle: storage.forcePathStyle ?? true, }) } else { - form.reset({ ...DEFAULT_VALUES, egressCreditBillingEnabled: false }) + form.reset(DEFAULT_VALUES) } setShowSecret(false) - }, [open, storage, form, hasTrafficBilling]) + }, [open, storage, form]) const mutation = useMutation({ - mutationFn: ({ - capacityValue, - capacityUnit, - egressCreditUnitValue, - egressCreditUnit, - ...rest - }: StorageFormValues) => { + mutationFn: ({ capacityValue, capacityUnit, ...rest }: StorageFormValues) => { const capacity = capacityValue * UNITS[capacityUnit] - const egressCreditUnitBytes = egressCreditUnitValue * UNITS[egressCreditUnit] const payload = { ...rest, - egressCreditBillingEnabled: hasTrafficBilling ? rest.egressCreditBillingEnabled : false, capacity, - egressCreditUnitBytes, } return isEditing ? updateStorage(storage.id, payload) : createStorage(payload) }, @@ -280,66 +257,6 @@ export function StorageFormDrawer({ open, onOpenChange, storage, hasTrafficBilli )} - -
-
-
- -

{t('admin.storages.egressBillingHint')}

-
- form.setValue('egressCreditBillingEnabled', hasTrafficBilling && checked)} - /> -
- {!hasTrafficBilling && ( -

{t('admin.storages.egressBillingBusinessOnly')}

- )} - {form.watch('egressCreditBillingEnabled') && ( -
- - {(controlProps) => ( -
- - -
- )} -
- - - -
- )} -
) } diff --git a/src/i18n/admin-storages-locale.test.ts b/src/i18n/admin-storages-locale.test.ts index 5d33ab3a..2801a38d 100644 --- a/src/i18n/admin-storages-locale.test.ts +++ b/src/i18n/admin-storages-locale.test.ts @@ -52,12 +52,16 @@ const ADMIN_STORAGES_KEYS = [ 'admin.storages.capacityUnlimited', 'admin.storages.capacityHint', 'admin.storages.egressBilling', + 'admin.storages.configureEgressBilling', + 'admin.storages.egressBillingTitle', + 'admin.storages.egressBillingDescription', 'admin.storages.egressBillingHint', 'admin.storages.egressBillingBusinessOnly', 'admin.storages.egressBillingUnit', 'admin.storages.egressBillingCredits', 'admin.storages.egressBillingRate', 'admin.storages.egressBillingOff', + 'admin.storages.egressBillingSaveSuccess', ] const ADMIN_NAV_KEYS = ['admin.nav.management', 'admin.nav.storages', 'admin.nav.users'] @@ -71,6 +75,7 @@ const INTERPOLATED_KEYS: Record = { 'admin.storages.deleteConfirm': ['{{title}}'], 'admin.storages.testUploadFailed': ['{{detail}}'], 'admin.storages.testCleanupFailed': ['{{detail}}'], + 'admin.storages.egressBillingDescription': ['{{title}}'], 'admin.storages.egressBillingRate': ['{{credits}}', '{{unit}}'], } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 2eccb770..75b04daf 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -759,12 +759,16 @@ "admin.storages.capacityUnlimited": "Unlimited", "admin.storages.capacityHint": "Maximum storage space. 0 means unlimited.", "admin.storages.egressBilling": "Traffic Credits billing", + "admin.storages.configureEgressBilling": "Configure egress billing", + "admin.storages.egressBillingTitle": "Egress billing", + "admin.storages.egressBillingDescription": "Configure download traffic Credits billing for {{title}}.", "admin.storages.egressBillingHint": "Charge workspace Credits for download traffic from this storage.", "admin.storages.egressBillingBusinessOnly": "Traffic billing is available with a Business license.", "admin.storages.egressBillingUnit": "Billing unit", "admin.storages.egressBillingCredits": "Credits per unit", "admin.storages.egressBillingRate": "{{credits}} Credit per {{unit}}", "admin.storages.egressBillingOff": "Off", + "admin.storages.egressBillingSaveSuccess": "Egress billing updated.", "admin.storages.customHostPlaceholder": "Optional", "settings.title": "Settings", "settings.tabProfile": "Profile", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index e237a369..df1f69f7 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -759,12 +759,16 @@ "admin.storages.capacityUnlimited": "不限制", "admin.storages.capacityHint": "最大存储空间,0 表示不限制。", "admin.storages.egressBilling": "流量 Credits 计费", + "admin.storages.configureEgressBilling": "配置流量计费", + "admin.storages.egressBillingTitle": "流量计费", + "admin.storages.egressBillingDescription": "配置 {{title}} 的下载流量 Credits 计费。", "admin.storages.egressBillingHint": "对此存储产生的下载流量扣除工作区 Credits。", "admin.storages.egressBillingBusinessOnly": "流量计费需要 Business 授权。", "admin.storages.egressBillingUnit": "计费单位", "admin.storages.egressBillingCredits": "每单位 Credits", "admin.storages.egressBillingRate": "每 {{unit}} {{credits}} Credit", "admin.storages.egressBillingOff": "关闭", + "admin.storages.egressBillingSaveSuccess": "流量计费已更新。", "admin.storages.customHostPlaceholder": "可选", "settings.title": "设置", "settings.tabProfile": "基本信息", diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index 6623f289..15726918 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -124,11 +124,13 @@ import { transferObject, updateAnnouncement, updateDownloader, + updateDownloaderCreditBilling, updateDownloadTask, updateIhostConfig, updateObject, updateOrgEntitlement, updateStorage, + updateStorageEgressBilling, updateUserEntitlement, uploadAvatar, uploadPartToS3, @@ -1253,6 +1255,28 @@ describe('api', () => { expect(init.body).toBe(JSON.stringify(body)) }) + it('updates downloader credit billing via dedicated route', async () => { + const payload = { id: 'downloader-1', remoteDownloadCreditBillingEnabled: true } + const body = { enabled: true, unitBytes: 2048, creditsPerUnit: 3 } + vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload)) + + const result = await updateDownloaderCreditBilling('downloader-1', body) + + expect(result).toEqual(payload) + const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] + expect(url).toContain('/api/downloads/downloaders/downloader-1/credit-billing') + expect(init.method).toBe('PUT') + expect(init.body).toBe(JSON.stringify(body)) + }) + + it('throws ApiError on downloader credit billing failure', async () => { + vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Feature not available' }, false, 402)) + + await expect( + updateDownloaderCreditBilling('downloader-1', { enabled: true, unitBytes: 1, creditsPerUnit: 1 }), + ).rejects.toBeInstanceOf(ApiError) + }) + it('deletes an admin downloader (resolves on 204)', async () => { vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null, true, 204)) @@ -1513,6 +1537,30 @@ describe('api', () => { }) }) + describe('updateStorageEgressBilling', () => { + it('puts storage egress billing data and returns updated storage', async () => { + const storage = { id: 's1', egressCreditBillingEnabled: true } + const body = { enabled: true, unitBytes: 2048, creditsPerUnit: 3 } + vi.mocked(fetch).mockResolvedValueOnce(makeResponse(storage)) + + const result = await updateStorageEgressBilling('s1', body) + + expect(result).toEqual(storage) + const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] + expect(url).toContain('/api/site/storages/s1/egress-billing') + expect(init.method).toBe('PUT') + expect(init.body).toBe(JSON.stringify(body)) + }) + + it('throws ApiError on error response', async () => { + vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Feature not available' }, false, 402)) + + await expect( + updateStorageEgressBilling('s1', { enabled: true, unitBytes: 1, creditsPerUnit: 1 }), + ).rejects.toBeInstanceOf(ApiError) + }) + }) + describe('deleteStorage', () => { it('sends DELETE request (resolves on 204)', async () => { vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null, true, 204)) diff --git a/src/lib/api.ts b/src/lib/api.ts index 66ccf5c9..1269a9c5 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -16,8 +16,10 @@ import type { DownloadTaskActionInput, PresignObjectUploadPartsInput, RedeemGiftCardResponse, + UpdateDownloaderCreditBillingInput, UpdateDownloaderInput, UpdateDownloadTaskInput, + UpdateStorageEgressBillingInput, UpdateStorageInput, } from '@shared/schemas' import type { @@ -368,6 +370,10 @@ export function updateDownloader(id: string, data: UpdateDownloaderInput) { return unwrap(adminDownloadersApi[':id'].$patch({ param: { id }, json: data })) } +export function updateDownloaderCreditBilling(id: string, data: UpdateDownloaderCreditBillingInput) { + return unwrap(adminDownloadersApi[':id']['credit-billing'].$put({ param: { id }, json: data })) +} + export function deleteDownloader(id: string) { return discard(adminDownloadersApi[':id'].$delete({ param: { id } })) } @@ -432,6 +438,10 @@ export function updateStorage(id: string, data: UpdateStorageInput) { return unwrap(storages[':id'].$put({ param: { id }, json: data })) } +export function updateStorageEgressBilling(id: string, data: UpdateStorageEgressBillingInput) { + return unwrap(storages[':id']['egress-billing'].$put({ param: { id }, json: data })) +} + export function deleteStorage(id: string) { return discard(storages[':id'].$delete({ param: { id } })) } diff --git a/src/routes/_authenticated/admin/downloaders.test.tsx b/src/routes/_authenticated/admin/downloaders.test.tsx new file mode 100644 index 00000000..0b6fd0d0 --- /dev/null +++ b/src/routes/_authenticated/admin/downloaders.test.tsx @@ -0,0 +1,135 @@ +import type { Downloader } from '@shared/types' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { listDownloaders, updateDownloader, updateDownloaderCreditBilling } from '@/lib/api' +import { AdminDownloadersPage } from './downloaders' + +const mockHasFeature = vi.hoisted(() => vi.fn((_feature: string) => true)) + +class TestResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, values?: Record) => { + if (!values) return key + return Object.entries(values).reduce((message, [name, value]) => message.replace(`{{${name}}}`, value), key) + }, + }), +})) + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})) + +vi.mock('@/hooks/useEntitlement', () => ({ + useEntitlement: () => ({ + hasFeature: mockHasFeature, + }), +})) + +vi.mock('@/lib/api', () => ({ + deleteDownloader: vi.fn(), + listDownloaders: vi.fn(), + updateDownloader: vi.fn(), + updateDownloaderCreditBilling: vi.fn(), +})) + +const downloader: Downloader = { + id: 'downloader-1', + name: 'Edge downloader', + status: 'online', + enabled: true, + version: '1.0.0', + hostname: 'edge-1', + platform: 'linux', + arch: 'amd64', + engine: 'aria2', + capabilities: ['http'], + maxConcurrentTasks: 2, + currentTasks: 0, + downloadBps: 0, + uploadBps: 0, + freeDiskBytes: 1024, + remoteDownloadCreditBillingEnabled: true, + remoteDownloadCreditUnitBytes: 100 * 1024 * 1024, + remoteDownloadCreditPerUnit: 2, + lastHeartbeatAt: null, + createdBy: 'user-1', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', +} + +function renderDownloadersPage() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) + + return render( + + + , + ) +} + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() + vi.clearAllMocks() +}) + +beforeEach(() => { + mockHasFeature.mockReturnValue(true) +}) + +describe('AdminDownloadersPage billing drawer', () => { + it('saves credit billing through the dedicated wrapper', async () => { + vi.stubGlobal('ResizeObserver', TestResizeObserver) + vi.mocked(listDownloaders).mockResolvedValue({ items: [downloader], total: 1, page: 1, pageSize: 1 }) + vi.mocked(updateDownloaderCreditBilling).mockResolvedValue(downloader) + + renderDownloadersPage() + + fireEvent.click(await screen.findByRole('button', { name: 'admin.downloaders.configureBilling' })) + await screen.findByText('admin.downloaders.billingTitle') + fireEvent.change(screen.getByLabelText('admin.downloaders.billingCredits'), { target: { value: '5' } }) + fireEvent.click(screen.getByRole('button', { name: 'common.save' })) + + await waitFor(() => + expect(updateDownloaderCreditBilling).toHaveBeenCalledWith('downloader-1', { + enabled: true, + unitBytes: 100 * 1024 * 1024, + creditsPerUnit: 5, + }), + ) + expect(updateDownloader).not.toHaveBeenCalled() + }) + + it('shows credit billing as view-only without the quota store entitlement', async () => { + vi.stubGlobal('ResizeObserver', TestResizeObserver) + mockHasFeature.mockImplementation((feature) => feature !== 'quota_store') + vi.mocked(listDownloaders).mockResolvedValue({ items: [downloader], total: 1, page: 1, pageSize: 1 }) + + renderDownloadersPage() + + fireEvent.click(await screen.findByRole('button', { name: 'admin.downloaders.configureBilling' })) + await screen.findByText('admin.downloaders.billingBusinessOnly') + + expect(screen.queryByRole('button', { name: 'common.save' })).toBeNull() + expect(screen.getByLabelText('admin.downloaders.billingCredits')).toHaveProperty('disabled', true) + expect(screen.getAllByRole('button', { name: 'common.close' })).toHaveLength(2) + + expect(updateDownloaderCreditBilling).not.toHaveBeenCalled() + expect(updateDownloader).not.toHaveBeenCalled() + }) +}) diff --git a/src/routes/_authenticated/admin/downloaders.tsx b/src/routes/_authenticated/admin/downloaders.tsx index b2c2ae8f..fead9a8c 100644 --- a/src/routes/_authenticated/admin/downloaders.tsx +++ b/src/routes/_authenticated/admin/downloaders.tsx @@ -5,6 +5,7 @@ import { Activity, Pencil, Settings2, Trash2 } from 'lucide-react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' +import { AdminFormDrawer, AdminFormField } from '@/components/admin/admin-form-drawer' import { AdminPageHeader } from '@/components/admin/admin-page-header' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' @@ -22,7 +23,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Switch } from '@/components/ui/switch' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { useEntitlement } from '@/hooks/useEntitlement' -import { deleteDownloader, listDownloaders, updateDownloader } from '@/lib/api' +import { deleteDownloader, listDownloaders, updateDownloader, updateDownloaderCreditBilling } from '@/lib/api' export const Route = createFileRoute('/_authenticated/admin/downloaders')({ component: AdminDownloadersPage, @@ -39,7 +40,7 @@ type CreditBillingForm = { credits: string } -function AdminDownloadersPage() { +export function AdminDownloadersPage() { const { t } = useTranslation() const queryClient = useQueryClient() const { hasFeature } = useEntitlement() @@ -86,7 +87,10 @@ function AdminDownloadersPage() { const billingMutation = useMutation({ mutationFn: ({ downloader, form }: { downloader: Downloader; form: CreditBillingForm }) => - updateDownloader(downloader.id, billingPayload(hasTrafficBilling ? form : { ...form, enabled: false })), + updateDownloaderCreditBilling( + downloader.id, + billingPayload(hasTrafficBilling ? form : { ...form, enabled: false }), + ), onSuccess: () => { setBillingTarget(null) queryClient.invalidateQueries({ queryKey: QUERY_KEY }) @@ -102,10 +106,7 @@ function AdminDownloadersPage() { } const openBillingSettings = (downloader: Downloader) => { setBillingTarget(downloader) - setBillingForm({ - ...billingFormFromDownloader(downloader), - enabled: hasTrafficBilling && downloader.remoteDownloadCreditBillingEnabled, - }) + setBillingForm(billingFormFromDownloader(downloader)) } return ( @@ -168,7 +169,7 @@ function AdminDownloadersPage() { }} onConfirm={() => renameTarget && renameMutation.mutate({ downloader: renameTarget, name: renameValue })} /> - = 1 && Number(form.credits) >= 1 + const canSave = hasTrafficBilling && valid return ( - - - - {t('admin.downloaders.billingTitle')} - - {t('admin.downloaders.billingDescription', { name: downloader?.name ?? '' })} - - -
{ - event.preventDefault() - if (valid) onConfirm() - }} - > -
-
- -

{t('admin.downloaders.billingEnabledHint')}

-
- onFormChange({ ...form, enabled: hasTrafficBilling && enabled })} - /> -
- {!hasTrafficBilling && ( -

{t('admin.downloaders.billingBusinessOnly')}

- )} -
-
- -
- onFormChange({ ...form, unitValue: event.target.value })} - /> - -
-
-
- - onFormChange({ ...form, credits: event.target.value })} - /> -
-
- + { + event.preventDefault() + if (canSave) onConfirm() + }, + }} + footer={ + hasTrafficBilling ? ( + <> - - -
-
-
+ + ) : ( + + ) + } + > +
+
+ +

{t('admin.downloaders.billingEnabledHint')}

+
+ onFormChange({ ...form, enabled: hasTrafficBilling && enabled })} + /> +
+ {!hasTrafficBilling && ( +

{t('admin.downloaders.billingBusinessOnly')}

+ )} +
+ + {(controlProps) => ( +
+ onFormChange({ ...form, unitValue: event.target.value })} + /> + +
+ )} +
+ + onFormChange({ ...form, credits: event.target.value })} + /> + +
+ ) } @@ -488,9 +495,9 @@ function billingFormFromDownloader(downloader: Downloader): CreditBillingForm { function billingPayload(form: CreditBillingForm) { return { - remoteDownloadCreditBillingEnabled: form.enabled, - remoteDownloadCreditUnitBytes: Math.max(1, Math.floor(Number(form.unitValue))) * CREDIT_UNITS[form.unit], - remoteDownloadCreditPerUnit: Math.max(1, Math.floor(Number(form.credits))), + enabled: form.enabled, + unitBytes: Math.max(1, Math.floor(Number(form.unitValue))) * CREDIT_UNITS[form.unit], + creditsPerUnit: Math.max(1, Math.floor(Number(form.credits))), } } diff --git a/src/routes/_authenticated/admin/storages/index.test.tsx b/src/routes/_authenticated/admin/storages/index.test.tsx index 6306b621..613d01cd 100644 --- a/src/routes/_authenticated/admin/storages/index.test.tsx +++ b/src/routes/_authenticated/admin/storages/index.test.tsx @@ -1,11 +1,19 @@ import { ObjectStatus, StorageStatus } from '@shared/constants' import type { Storage } from '@shared/types' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { abortObjectUpload, type CreateObjectResult, createObject, listStorages } from '@/lib/api' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + abortObjectUpload, + type CreateObjectResult, + createObject, + listStorages, + updateStorageEgressBilling, +} from '@/lib/api' import { corsJsonForOrigin, StoragesPage } from './index' +const mockHasFeature = vi.hoisted(() => vi.fn((_feature: string) => true)) + vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string, values?: Record) => { @@ -29,7 +37,7 @@ vi.mock('@/components/admin/storage-form-drawer', () => ({ vi.mock('@/hooks/useEntitlement', () => ({ useEntitlement: () => ({ - hasFeature: () => true, + hasFeature: mockHasFeature, }), })) @@ -37,6 +45,7 @@ vi.mock('@/lib/api', () => ({ abortObjectUpload: vi.fn(), createObject: vi.fn(), listStorages: vi.fn(), + updateStorageEgressBilling: vi.fn(), })) const storage: Storage = { @@ -60,6 +69,12 @@ const storage: Storage = { updatedAt: '2026-01-01T00:00:00.000Z', } +class TestResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + const uploadDraft: CreateObjectResult = { id: 'object-1', orgId: 'org-1', @@ -99,6 +114,10 @@ afterEach(() => { vi.clearAllMocks() }) +beforeEach(() => { + mockHasFeature.mockReturnValue(true) +}) + describe('admin storages CORS guidance', () => { it('renders the bucket CORS policy required for browser-based storage tests', () => { expect(JSON.parse(corsJsonForOrigin('https://preview.example.com'))).toEqual([ @@ -167,4 +186,40 @@ describe('StoragesPage connection test action', () => { expect(view.container.textContent).toContain('"MaxAgeSeconds": 3600') expect(abortObjectUpload).toHaveBeenCalledWith('object-1', 'session-1', { strictStorageCleanup: true }) }) + + it('opens egress billing from the row action and saves through the dedicated wrapper', async () => { + vi.stubGlobal('ResizeObserver', TestResizeObserver) + vi.mocked(listStorages).mockResolvedValue({ items: [{ ...storage, egressCreditBillingEnabled: true }], total: 1 }) + vi.mocked(updateStorageEgressBilling).mockResolvedValue(storage) + + const view = renderStoragesPage() + fireEvent.click(await view.findByTitle('admin.storages.configureEgressBilling')) + await view.findByText('admin.storages.egressBillingTitle') + fireEvent.change(screen.getByLabelText('admin.storages.egressBillingCredits'), { target: { value: '4' } }) + fireEvent.click(screen.getByRole('button', { name: 'common.save' })) + + await waitFor(() => + expect(updateStorageEgressBilling).toHaveBeenCalledWith('storage-1', { + enabled: true, + unitBytes: 1024 * 1024 * 1024, + creditsPerUnit: 4, + }), + ) + }) + + it('shows egress billing as view-only without the quota store entitlement', async () => { + vi.stubGlobal('ResizeObserver', TestResizeObserver) + mockHasFeature.mockImplementation((feature) => feature !== 'quota_store') + vi.mocked(listStorages).mockResolvedValue({ items: [{ ...storage, egressCreditBillingEnabled: true }], total: 1 }) + + const view = renderStoragesPage() + fireEvent.click(await view.findByTitle('admin.storages.configureEgressBilling')) + + await view.findByText('admin.storages.egressBillingBusinessOnly') + expect(screen.queryByRole('button', { name: 'common.save' })).toBeNull() + expect(screen.getByLabelText('admin.storages.egressBillingCredits')).toHaveProperty('disabled', true) + expect(screen.getAllByRole('button', { name: 'common.close' })).toHaveLength(2) + + expect(updateStorageEgressBilling).not.toHaveBeenCalled() + }) }) diff --git a/src/routes/_authenticated/admin/storages/index.tsx b/src/routes/_authenticated/admin/storages/index.tsx index 26ec91d3..0bd2e7b0 100644 --- a/src/routes/_authenticated/admin/storages/index.tsx +++ b/src/routes/_authenticated/admin/storages/index.tsx @@ -1,16 +1,32 @@ import { FREE_STORAGE_LIMIT, StorageStatus } from '@shared/constants' import type { Storage } from '@shared/types' -import { useQuery } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { createFileRoute } from '@tanstack/react-router' -import { AlertTriangle, CheckCircle2, Database, Loader2, Pencil, Plus, TestTube2, Trash2 } from 'lucide-react' +import { + AlertTriangle, + CheckCircle2, + Database, + Loader2, + Pencil, + Plus, + Settings2, + TestTube2, + Trash2, +} from 'lucide-react' import { useState } from 'react' import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import { AdminFormDrawer, AdminFormField } from '@/components/admin/admin-form-drawer' import { DeleteStorageDialog } from '@/components/admin/delete-storage-dialog' import { StorageFormDrawer } from '@/components/admin/storage-form-drawer' import { UpgradeHint } from '@/components/UpgradeHint' import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' import { useEntitlement } from '@/hooks/useEntitlement' -import { ApiError, abortObjectUpload, createObject, listStorages } from '@/lib/api' +import { ApiError, abortObjectUpload, createObject, listStorages, updateStorageEgressBilling } from '@/lib/api' import { formatSize } from '@/lib/format' export const Route = createFileRoute('/_authenticated/admin/storages/')({ @@ -25,6 +41,15 @@ type StorageHealth = | { status: 'cors'; message: string; corsJson: string } const TEST_CONTENT = 'zpan storage connection test\n' +const CREDIT_UNITS = { MB: 1024 ** 2, GB: 1024 ** 3, TB: 1024 ** 4 } as const + +type CreditUnit = keyof typeof CREDIT_UNITS +type EgressBillingForm = { + enabled: boolean + unitValue: string + unit: CreditUnit + credits: string +} export function corsJsonForOrigin(origin: string) { return JSON.stringify( @@ -50,9 +75,12 @@ function readableError(error: unknown) { export function StoragesPage() { const { t } = useTranslation() + const queryClient = useQueryClient() const { hasFeature } = useEntitlement() const [formOpen, setFormOpen] = useState(false) const [editingStorage, setEditingStorage] = useState(null) + const [billingTarget, setBillingTarget] = useState(null) + const [billingForm, setBillingForm] = useState(emptyEgressBillingForm()) const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null) const [healthByStorage, setHealthByStorage] = useState>({}) @@ -65,6 +93,20 @@ export function StoragesPage() { const storagesLimitReached = !hasFeature('storages_unlimited') && storages.length >= FREE_STORAGE_LIMIT const hasTrafficBilling = hasFeature('quota_store') + const billingMutation = useMutation({ + mutationFn: ({ storage, form }: { storage: Storage; form: EgressBillingForm }) => + updateStorageEgressBilling( + storage.id, + egressBillingPayload(hasTrafficBilling ? form : { ...form, enabled: false }), + ), + onSuccess: () => { + setBillingTarget(null) + queryClient.invalidateQueries({ queryKey: ['admin', 'storages'] }) + toast.success(t('admin.storages.egressBillingSaveSuccess')) + }, + onError: (err) => toast.error(err.message), + }) + function handleEdit(storage: Storage) { setEditingStorage(storage) setFormOpen(true) @@ -76,6 +118,11 @@ export function StoragesPage() { setFormOpen(true) } + function handleConfigureBilling(storage: Storage) { + setBillingTarget(storage) + setBillingForm(egressBillingFormFromStorage(storage)) + } + function handleFormOpenChange(open: boolean) { setFormOpen(open) if (!open) setEditingStorage(null) @@ -188,6 +235,7 @@ export function StoragesPage() { health={healthByStorage[storage.id] ?? { status: 'idle' }} onTest={() => handleTest(storage)} onEdit={() => handleEdit(storage)} + onConfigureBilling={() => handleConfigureBilling(storage)} onDelete={() => setDeleteTarget({ id: storage.id, title: storage.title })} /> ))} @@ -205,11 +253,17 @@ export function StoragesPage() { - + + !open && setBillingTarget(null)} + onConfirm={() => billingTarget && billingMutation.mutate({ storage: billingTarget, form: billingForm })} /> void onEdit: () => void + onConfigureBilling: () => void onDelete: () => void }) { const { t } = useTranslation() @@ -277,6 +333,14 @@ function StorageTableRow({ + @@ -286,6 +350,146 @@ function StorageTableRow({ ) } +function StorageEgressBillingDrawer({ + storage, + form, + open, + pending, + hasTrafficBilling, + onFormChange, + onOpenChange, + onConfirm, +}: { + storage: Storage | null + form: EgressBillingForm + open: boolean + pending: boolean + hasTrafficBilling: boolean + onFormChange: (form: EgressBillingForm) => void + onOpenChange: (open: boolean) => void + onConfirm: () => void +}) { + const { t } = useTranslation() + const valid = Number(form.unitValue) >= 1 && Number(form.credits) >= 1 + const canSave = hasTrafficBilling && valid + + return ( + { + event.preventDefault() + if (canSave) onConfirm() + }, + }} + footer={ + hasTrafficBilling ? ( + <> + + + + ) : ( + + ) + } + > +
+
+
+ +

{t('admin.storages.egressBillingHint')}

+
+ onFormChange({ ...form, enabled: hasTrafficBilling && enabled })} + /> +
+ {!hasTrafficBilling && ( +

{t('admin.storages.egressBillingBusinessOnly')}

+ )} +
+ +
+ + {(controlProps) => ( +
+ onFormChange({ ...form, unitValue: event.target.value })} + /> + +
+ )} +
+ + onFormChange({ ...form, credits: event.target.value })} + /> + +
+
+ ) +} + +function emptyEgressBillingForm(): EgressBillingForm { + return { enabled: false, unitValue: '100', unit: 'MB', credits: '1' } +} + +function egressBillingFormFromStorage(storage: Storage): EgressBillingForm { + const unit = bytesToCreditUnit(storage.egressCreditUnitBytes) + return { + enabled: storage.egressCreditBillingEnabled, + unitValue: String(Math.max(1, storage.egressCreditUnitBytes / CREDIT_UNITS[unit])), + unit, + credits: String(storage.egressCreditPerUnit), + } +} + +function egressBillingPayload(form: EgressBillingForm) { + return { + enabled: form.enabled, + unitBytes: Math.max(1, Math.floor(Number(form.unitValue))) * CREDIT_UNITS[form.unit], + creditsPerUnit: Math.max(1, Math.floor(Number(form.credits))), + } +} + +function bytesToCreditUnit(bytes: number): CreditUnit { + if (bytes >= CREDIT_UNITS.TB && bytes % CREDIT_UNITS.TB === 0) return 'TB' + if (bytes >= CREDIT_UNITS.GB && bytes % CREDIT_UNITS.GB === 0) return 'GB' + return 'MB' +} + function StorageHealthView({ health }: { health: StorageHealth }) { const { t } = useTranslation()