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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -182,19 +182,18 @@ export default defineConfig([
// that needs it, and read by a plain mapper function via `readInlineData`
// (`device-{row,selector,}-fields.ts` compose into a ladder this way). The
// consumer is a function in another file by construction.
// - `subscription-settings-view` spreads the device-plan-picker's fragments
// and hands the refs down through PaywallBody and DeviceManagementCard to
// the picker that reads them. Passing a fragment ref through an intermediate
// component is ordinary Relay; the rule only recognises a direct render.
// - `use-plan-checkout` (the paywall form's query, shared by the lock screen
// and the Activate Subscription modal) spreads the device-plan-picker's
// fragments and hands the refs down through PlanCheckoutCards and
// DeviceManagementCard to the picker that reads them. Passing a fragment
// ref through an intermediate component is ordinary Relay; the rule only
// recognises a direct render.
//
// Everywhere else the rule stays on, which is where it earns its place: a
// component-owned fragment spread far from the component that reads it is how
// Relay codebases rot.
name: 'openframe-frontend/fragment-definitions-render-nothing',
files: [
'src/graphql/**/*.ts',
'src/app/(app)/settings/billing-usage/subscription/components/subscription-settings-view.tsx',
],
files: ['src/graphql/**/*.ts', 'src/app/(app)/settings/billing-usage/subscription/hooks/use-plan-checkout.ts'],
rules: { 'relay/must-colocate-fragment-spreads': 'off' },
},

Expand Down
73 changes: 68 additions & 5 deletions schema.graphql
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Auto-generated by scripts/fetch-schema.mjs via introspection
# Source: https://tst-notifications.dev.openframe.build/api/graphql
# Generated: 2026-09-14T16:34:07.687Z
# Source: https://bnk-tkn.dev4.openframe.build/api/graphql
# Generated: 2026-09-14T19:34:36.356Z
#
# Do not edit manually. Re-run: npm run fetch-schema
directive @extends on OBJECT | INTERFACE
Expand Down Expand Up @@ -137,6 +137,12 @@ enum BillingMetricType {
enum BillingPeriod {
MONTHLY
YEARLY

"""
Bought outright rather than on a cycle: the period of a prepaid package, which is paid for once and
lasts until it is spent. A plan that holds one cannot be serialized at all without this value.
"""
UNLIMITED
}

""" --- Billing Plan (detailed) ---"""
Expand Down Expand Up @@ -235,6 +241,14 @@ input CancelSubscriptionInput {
input CheckoutInput {
products: [ProductCheckoutInput!]!
discountCode: String

"""
Money to put towards AI tokens as part of this checkout. Required, and at least the configured
minimum, unless the requirement is switched off by configuration: a subscription that starts with an
empty bank can use AI only until its free grant runs out. The amount is charged on the checkout's own
invoice, and the tokens land when it is paid.
"""
tokenAmountUsd: Float
}

""" --- Subscription / Checkout ---"""
Expand Down Expand Up @@ -1277,6 +1291,19 @@ type Mutation {
"""
updateAiSpendCap(amountUsd: Float): SubscriptionDetail!

"""
Buy AI tokens for the given amount in USD. The amount must be a whole number of dollars and at
least the configured minimum; the backend converts it to a quantity at the current tiered price
and raises an invoice for it. Tokens are credited only once that invoice is paid, so the value
returned is where the customer goes to pay it.

Available to a tenant that has run out of tokens: hitting the AI budget stops AI, not the purchase
that lifts it. Not available while the subscription itself is blocked — past due, suspended,
cancelled, or an expired trial — because we do not sell more to a tenant that already owes: settle
first, then buy.
"""
purchaseTokens(amountUsd: Float!): TokenPurchase!

"""
Update the current authenticated tenant's name and/or website. saas-api only.
"""
Expand Down Expand Up @@ -1878,8 +1905,12 @@ enum PriceModel {
}

type PriceTier {
from: Int!
upTo: Int
"""
First quantity this rate applies to. A Long because a token tier is counted in tokens: at ten dollars a
million, a ten-thousand-dollar tier already stands at a billion, well past what an Int can hold.
"""
from: Long!
upTo: Long
unitPrice: Float!
}

Expand All @@ -1901,7 +1932,14 @@ input ProductCheckoutInput {
productName: OpenframeProduct!
packageOptionId: String
quantity: Int
payAsYouGoEnabled: Boolean = true

"""
Whether the product is billed by the meter beyond its allowance. Leave it out and the product decides:
on for a metered product, off for one sold in advance. There is no default here because a single one
could only ever be right for one kind, and asking for the meter on a product sold in advance is
refused rather than quietly ignored.
"""
payAsYouGoEnabled: Boolean
enabled: Boolean = true
}

Expand Down Expand Up @@ -2982,6 +3020,19 @@ type SubscriptionUsage {
What the AI overage accrued so far costs, in USD. This is what aiSpendCapUsd is measured against.
"""
aiSpendUsd: Float!

"""
Prepaid tokens still available, net of anything owed, and never below zero: a tenant that kept working
through an automatic top-up has nothing left to spend rather than a negative amount of it. What it owes
is settled by the next purchase and is not reported here.
"""
purchasedTokensRemaining: Long!

"""
What those tokens are worth, priced at the rate each pack was bought at and excluding tax. Zero
whenever purchasedTokensRemaining is, so the two never contradict each other.
"""
purchasedTokensRemainingUsd: Float!
}

""" Tag key definition (shared across all entity types)"""
Expand Down Expand Up @@ -3178,6 +3229,18 @@ enum TimerState {
COMPLETED
}

"""What a token purchase left the customer with: an invoice to pay."""
type TokenPurchase {
"""Tokens the amount bought at the price in force when it was quoted."""
tokens: Long!

"""Amount charged, in USD, excluding tax."""
amountUsd: Float!

"""Where the customer pays the invoice. Tokens land once it is paid."""
paymentUrl: String!
}

type ToolConnection implements Node {
id: ID!
machineId: String!
Expand Down
2 changes: 1 addition & 1 deletion src/app/(app)/checkout/cancel/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export default function CheckoutCancelPage() {
iconWrapperClassName="bg-ods-error-secondary text-ods-error"
title="Payment Cancelled"
description="No charges were made. You can pick a plan whenever you're ready."
primaryCta={{ label: 'Back to Billing', href: routes.settings.billingUsage }}
primaryCta={{ label: 'Back to Billing', href: routes.settings.billingUsage() }}
secondaryCta={{ label: 'Go to Dashboard', href: routes.dashboard }}
pending={gate === 'loading'}
/>
Expand Down
2 changes: 1 addition & 1 deletion src/app/(app)/checkout/success/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export default function CheckoutSuccessPage() {
title="Payment Successful"
description="Thanks for subscribing. Your plan is activating now — it may take a moment to show up across the app."
primaryCta={{ label: 'Continue to Dashboard', href: routes.dashboard }}
secondaryCta={{ label: 'View Subscription', href: routes.settings.billingUsage }}
secondaryCta={{ label: 'View Subscription', href: routes.settings.billingUsage() }}
pending={gate === 'loading'}
/>
);
Expand Down

This file was deleted.

Loading
Loading