Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions common/config/rush/command-line.json
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,14 @@
"safeForSimultaneousRushProcesses": true,
"shellCommand": "cd ./dev && docker compose -f docker-compose.yaml -f docker-compose.min.yaml up -d --force-recreate"
},
{
"commandKind": "global",
"name": "docker:up:ext",
"summary": "Up extended development stack with billing + payment",
"description": "Start extended docker compose (docker-compose.yaml + docker-compose.ext.yaml) with the optional billing and payment services enabled. Required env: STRIPE_API_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_SUBSCRIPTION_PLANS (or POLAR_* equivalents).",
"safeForSimultaneousRushProcesses": true,
"shellCommand": "cd ./dev && docker compose -f docker-compose.yaml -f docker-compose.ext.yaml up -d --force-recreate"
},
{
"commandKind": "global",
"name": "docker:up:pg",
Expand Down
8 changes: 7 additions & 1 deletion common/config/rush/pnpm-lock.yaml

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

80 changes: 80 additions & 0 deletions dev/docker-compose.ext.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Ext overlay: enables the optional billing + payment services so that the client
# can reach them and plan-limit restrictions kick in.
# Use with: docker compose -f docker-compose.yaml -f docker-compose.ext.yaml up
# or: rush docker:up:ext
# (or: docker compose -f docker-compose.yaml -f docker-compose.min.yaml -f docker-compose.ext.yaml up
# if you want to keep the small footprint and just add billing/payment on top.)
#
# The base docker-compose.yaml already advertises PAYMENT_URL=http://huly.local:3040
# to the front. This overlay materialises the matching `payment` and `billing`
# services and adds BILLING_URL to the front so the client picks them up.
#
# Provider credentials are read from the host environment (e.g. dev/.env or shell):
# PAYMENT_USE_SANDBOX default: true
# POLAR_ACCESS_TOKEN, POLAR_WEBHOOK_SECRET, POLAR_ORGANIZATION_ID,
# POLAR_SUBSCRIPTION_PLANS
# STRIPE_API_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_SUBSCRIPTION_PLANS
# All are optional — without them the service starts in sandbox mode.

services:
payment:
image: hardcoreeng/payment
# Clear the profile gate from docker-compose.min.yaml so the service starts
# whenever this overlay is included, even alongside `min`.
profiles: !override []
extra_hosts:
- 'huly.local:host-gateway'
depends_on:
account:
condition: service_started
ports:
- 3040:3040
environment:
- PORT=3040
- SECRET=secret
- ACCOUNTS_URL=http://huly.local:3000
- FRONT_URL=http://huly.local:8087
- USE_SANDBOX=${PAYMENT_USE_SANDBOX:-true}
# Provider credentials — supply via .env / shell env. Empty values keep
# the service running in safe "sandbox" mode without making outbound calls.
# - POLAR_ACCESS_TOKEN=${POLAR_ACCESS_TOKEN:-}
# - POLAR_WEBHOOK_SECRET=${POLAR_WEBHOOK_SECRET:-}
# - POLAR_ORGANIZATION_ID=${POLAR_ORGANIZATION_ID:-}
# - POLAR_SUBSCRIPTION_PLANS=${POLAR_SUBSCRIPTION_PLANS:-}
- STRIPE_API_KEY=${STRIPE_API_KEY:-}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-}
- STRIPE_SUBSCRIPTION_PLANS=${STRIPE_SUBSCRIPTION_PLANS:-}
- OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318/v1/traces
restart: unless-stopped

billing:
image: hardcoreeng/billing
profiles: !override []
extra_hosts:
- 'huly.local:host-gateway'
depends_on:
cockroach:
condition: service_started
minio:
condition: service_healthy
account:
condition: service_started
ports:
- 4042:4042
environment:
- PORT=4042
- SECRET=secret
- ACCOUNTS_URL=http://huly.local:3000
- DB_URL=${DB_CR_URL}
- STORAGE_CONFIG=${STORAGE_CONFIG}
# Recheck workspace usage every 10 minutes in dev (default is 1 hour).
- USAGE_UPDATE_INTERVAL=600
- OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318/v1/traces
restart: unless-stopped

# Make the billing endpoint discoverable by the client. PAYMENT_URL is already
# set in the base compose file so we don't repeat it here — environment values
# are merged additively.
front:
environment:
- BILLING_URL=http://huly.local:4042
4 changes: 3 additions & 1 deletion dev/docker-compose.min.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Min override: excludes optional services from min deployment.
# Use with: docker compose -f docker-compose.yaml -f docker-compose.min.yaml up
# (rush docker:up:min)
# Excluded: preview, link-preview, elastic, redis, stats, payment, fulltext_cockroach,
# Excluded: preview, link-preview, elastic, redis, stats, payment, billing, fulltext_cockroach,
# print, sign, hulykvs, hulygun, hulypulse, process-service, backup-cockroach,
# backup-api, rating_cockroach
# Overrides below remove dependencies on excluded services so the min project validates.
Expand All @@ -19,6 +19,8 @@ services:
profiles: ["full"]
payment:
profiles: ["full"]
billing:
profiles: ["full"]
fulltext_cockroach:
profiles: ["full"]
print:
Expand Down
1 change: 1 addition & 0 deletions foundations/core/packages/core/src/classes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -927,6 +927,7 @@ export interface UsageStatus {
usage: Record<string, number>
startTime: Timestamp
updateTime: Timestamp
limitsExceededSince?: Timestamp // Timestamp when current usage first exceeded the workspace plan limits.
}

export interface WorkspaceInfoWithStatus extends WorkspaceInfo {
Expand Down
18 changes: 9 additions & 9 deletions models/billing/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { Model, UX, type Builder } from '@hcengineering/model'
import core, { TDoc } from '@hcengineering/model-core'
import { type IntlString } from '@hcengineering/platform'
import setting from '@hcengineering/setting'
import billing, { type Tier } from '@hcengineering/billing'
import billing, { TIER_LIMITS_GB, type Tier } from '@hcengineering/billing'
import { AccountRole, DOMAIN_MODEL } from '@hcengineering/core'
import presentation from '@hcengineering/model-presentation'
import workbench from '@hcengineering/workbench'
Expand Down Expand Up @@ -58,8 +58,8 @@ export function createModel (builder: Builder): void {
{
label: billing.string.Common,
description: billing.string.CommonDescription,
storageLimitGB: 10,
trafficLimitGB: 10,
storageLimitGB: TIER_LIMITS_GB.common.storageGB,
trafficLimitGB: TIER_LIMITS_GB.common.trafficGB,
priceMonthly: 0,
index: 0
},
Expand All @@ -72,8 +72,8 @@ export function createModel (builder: Builder): void {
{
label: billing.string.Rare,
description: billing.string.RareDescription,
storageLimitGB: 100,
trafficLimitGB: 100,
storageLimitGB: TIER_LIMITS_GB.rare.storageGB,
trafficLimitGB: TIER_LIMITS_GB.rare.trafficGB,
priceMonthly: 19.99,
index: 1,
color: 'Sky'
Expand All @@ -87,8 +87,8 @@ export function createModel (builder: Builder): void {
{
label: billing.string.Epic,
description: billing.string.EpicDescription,
storageLimitGB: 1000,
trafficLimitGB: 500,
storageLimitGB: TIER_LIMITS_GB.epic.storageGB,
trafficLimitGB: TIER_LIMITS_GB.epic.trafficGB,
priceMonthly: 99.99,
index: 2,
color: 'Orchid'
Expand All @@ -102,8 +102,8 @@ export function createModel (builder: Builder): void {
{
label: billing.string.Legendary,
description: billing.string.LegendaryDescription,
storageLimitGB: 10000,
trafficLimitGB: 2000,
storageLimitGB: TIER_LIMITS_GB.legendary.storageGB,
trafficLimitGB: TIER_LIMITS_GB.legendary.trafficGB,
priceMonthly: 399.99,
index: 3,
color: 'Orange'
Expand Down
42 changes: 42 additions & 0 deletions packages/presentation/src/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,53 @@ export function getFileUrl (file: string, filename?: string): string {
return storage.getFileUrl(workspace, file, filename)
}

/**
* Error thrown by registered upload guards (see {@link setUploadGuard}) when the
* current workspace is not allowed to upload new files (e.g. plan limit reached
* and grace period expired). Caller code should handle this distinct from generic
* upload failures and surface a user-friendly message + upgrade CTA.
*
* @public
*/
export class UploadRestrictedError extends Error {
constructor (
public readonly reason: string,
message?: string
) {
super(message ?? reason)
this.name = 'UploadRestrictedError'
}
}

/** @public */
export type UploadGuard = (file: File) => Promise<void> | void

let uploadGuard: UploadGuard | undefined

/**
* Register a synchronous/async guard called before every {@link uploadFile}.
* Throw an {@link UploadRestrictedError} from the guard to block the upload.
* Pass `undefined` to clear the guard.
*
* The guard lives in `presentation` to keep upload restriction concerns out of
* every individual call site, and to avoid a dependency from `presentation` to
* higher-level plugins (billing-resources) — DI inversion via a setter.
*
* @public
*/
export function setUploadGuard (guard: UploadGuard | undefined): void {
uploadGuard = guard
}

/** @public */
export async function uploadFile (
file: File,
uuid?: Ref<PlatformBlob>
): Promise<{ uuid: Ref<PlatformBlob>, metadata: Record<string, any> }> {
if (uploadGuard !== undefined) {
await uploadGuard(file)
}

uuid ??= generateFileId() as Ref<PlatformBlob>

const token = getToken()
Expand Down
19 changes: 19 additions & 0 deletions packages/theme/styles/_colors.scss
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,14 @@
--theme-button-attention-active-shadow: 0 4px 12px rgba(124, 58, 237, 0.6);
--theme-button-attention-focus-shadow: 0 0 0 3px rgba(124, 58, 237, 0.3);

--theme-button-warning-color: #FB923C;
--theme-button-warning-bg: rgba(249, 115, 22, 0.12);
--theme-button-warning-border: rgba(249, 115, 22, 0.32);
--theme-button-warning-hover-bg: rgba(249, 115, 22, 0.18);
--theme-button-warning-hover-border: rgba(249, 115, 22, 0.45);
--theme-button-warning-active-bg: rgba(249, 115, 22, 0.24);
--theme-button-warning-focus-ring: 0 0 0 2px rgba(249, 115, 22, 0.35);

--theme-refinput-divider: rgba(255, 255, 255, .07);
--theme-refinput-border: rgba(255, 255, 255, .1);

Expand Down Expand Up @@ -467,6 +475,17 @@
--theme-button-attention-active-shadow: 0 4px 12px rgba(236, 72, 153, 0.5);
--theme-button-attention-focus-shadow: 0 0 0 3px rgba(236, 72, 153, 0.2);

// Warning button — subdued, tinted-orange variant for persistent slots
// (e.g. sidebar footer). Designed to read as "heads up" without competing
// for attention with primary actions. No gradients, no lift, no pulse.
--theme-button-warning-color: #C2410C;
--theme-button-warning-bg: rgba(249, 115, 22, 0.10);
--theme-button-warning-border: rgba(249, 115, 22, 0.30);
--theme-button-warning-hover-bg: rgba(249, 115, 22, 0.16);
--theme-button-warning-hover-border: rgba(249, 115, 22, 0.45);
--theme-button-warning-active-bg: rgba(249, 115, 22, 0.22);
--theme-button-warning-focus-ring: 0 0 0 2px rgba(249, 115, 22, 0.3);

--theme-refinput-divider: rgba(0, 0, 0, .07);
--theme-refinput-border: rgba(0, 0, 0, .1);

Expand Down
70 changes: 69 additions & 1 deletion packages/theme/styles/button.scss
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@
border-color: var(--theme-button-attention-hover-border);
transform: translateY(-2px);
box-shadow: var(--theme-button-attention-hover-shadow);

&::before { left: 100%; }
}

Expand Down Expand Up @@ -228,6 +228,41 @@
}
}

&.warning {
font-weight: 500;
border: 1px solid var(--theme-button-warning-border);
background: var(--theme-button-warning-bg);

.icon { color: var(--theme-button-warning-color); }
span { color: var(--theme-button-warning-color); }

&:not(.disabled, :disabled):hover {
background: var(--theme-button-warning-hover-bg);
border-color: var(--theme-button-warning-hover-border);
}

&:not(.disabled, :disabled):active,
&.pressed:not(.disabled, :disabled) {
background: var(--theme-button-warning-active-bg);
}

&:not(.no-focus):focus {
box-shadow: var(--theme-button-warning-focus-ring);
}

&:disabled:not(.loading),
&.disabled:not(.loading) {
background: var(--button-disabled-BackgroundColor);
border-color: var(--button-disabled-BackgroundColor);
}

&.loading {
background: var(--theme-button-warning-active-bg);

span { color: var(--theme-button-warning-color); }
}
}

& > * { pointer-events: none; }
}

Expand Down Expand Up @@ -648,6 +683,39 @@
.btn-right-icon { color: var(--primary-button-disabled-color); }
}
}
&.warning {
font-weight: 500;
color: var(--theme-button-warning-color);
background: var(--theme-button-warning-bg);
border: 1px solid var(--theme-button-warning-border);

.btn-icon,
.btn-right-icon { color: var(--theme-button-warning-color); }

&:hover {
background: var(--theme-button-warning-hover-bg);
border-color: var(--theme-button-warning-hover-border);
}

&:active,
&.pressed,
&.pressed:hover {
background: var(--theme-button-warning-active-bg);
}

&:not(.no-focus):focus {
box-shadow: var(--theme-button-warning-focus-ring);
}

&:disabled {
color: var(--primary-button-disabled-color);
background: var(--primary-button-disabled);
border-color: var(--primary-button-disabled);

.btn-icon,
.btn-right-icon { color: var(--primary-button-disabled-color); }
}
}
&.contrast {
padding: .75rem 1rem;
font-weight: 500;
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ export type ButtonKind =
| 'contrast'
| 'stepper'
| 'attention'
| 'warning'
export type ButtonSize = 'inline' | 'x-small' | 'small' | 'medium' | 'large' | 'x-large'
export type ButtonShape =
| 'rectangle'
Expand Down
Loading
Loading