mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
Reset the PAYG free grant each billing period (#7709)
Needs the schema half: Stirling-Tools/Stirling-PDF-SaaS#327 ## Current state The PAYG free allowance is a one-time lifetime pool. `pricing_policy.free_tier_units` is copied into `payg_team_extensions.free_units_remaining` once, at team creation (V14 trigger, updated in V19), and the charge pipeline decrements it until it reaches zero. Nothing ever puts it back. ## Problem The product promises a monthly allowance the billing model does not grant. - The account-link connect dialog advertises "500 free per month". That has **merged to main** (#7415), so the claim is live and unhonoured until this lands. - The wallet meter already read "Process 500 PDFs free, then $X/PDF", which reads as an allowance-then-meter model. - `SignupRequiredBootstrap`'s own doc comment described a "free 500-op/month allowance" while its copy said only "500 free operations". Three separate comments asserted the opposite in code (`billing/types.ts`, `WalletSnapshotResponse`, `TeamBillingContext`), so the two halves of the repo disagreed about what a customer is owed. ## Solution The grant now recurs each billing period, **for every team**. Paying does not cost you the allowance: a subscribed team draws its grant first each period and meters only the excess, which is what the meter's copy always described. That also matches how the grant already worked at charge time, where it reduced metered units regardless of subscription. ### The reset is lazy, with no scheduler `payg_team_extensions` gains `free_units_period_start`: the period `free_units_remaining` was last written for. - A stamp older than the current period start, or absent as on every existing row, means the reset is owed. `TeamBillingService.remainingForPeriod` projects it to a full grant, so the entitlement gate and the wallet both show it the instant the period turns. - `JobChargeService.consumeFreeGrant` persists it on the next charge, under the pessimistic row lock that already makes the per-job free/paid split exact. One rule, both callers, so display and enforcement cannot drift onto separate schedules. A team that runs nothing for a month has nothing to write, and no job is needed to hand out the grant. ### One period definition "Per period" is `TeamBillingContext.periodStart`: the Stripe subscription's current period when subscribed, the calendar month otherwise. It was already the only period notion in the system, so the grant joined it rather than inventing its own: - `InstanceEntitlement.periodCapUnits` is enforced over the same window. - `localUsageService.currentPeriodUnsynced` already buckets a linked instance's local usage by the `periodStart` it reads from the same snapshot, and resets its counters on that boundary. For an un-subscribed team, the only kind the grant gates, that window is the calendar month, which is what the copy promises. The period rule stays in Java by choice, not necessity: SQL could reach the Stripe period through the sync engine, but restating the rule there would give it a second home to drift from. Hence a nullable column and no backfill in the migration — NULL already means "stale", so every existing team reads as owed the current period's grant. ### Refunds A refund landing after the period turned would have stacked last period's units on top of the fresh grant. `JobChargeService.restoreFreeGrant` now clamps the restore to one period's grant, taking the same row lock, and the bulk-increment `restoreFreeUnits` query is gone. Removing it also removed a `@Query` string that no test would have parsed before application startup. ### Copy and comments Every comment and user-facing string that asserted the lifetime model is corrected. The strings that changed (code defaults and `en-US` TOML updated together): | Key | Now reads | | --- | --- | | `portal.billing.walletMeter.title` / `titleWithRate` | "500 free credits every month, then $X per PDF" | | `portal.billing.walletMeter.capSuffix` / `barAria` | "of 500 free credits left this month" / "Free credits remaining" | | `payg.free.hero.capSuffix` | "of 500 free PDFs left this month" | | `plan.freeLimit.message` | "...this month. ... It resets next month, or keep the momentum going now..." | | `payg.signupRequired.body` | "500 free operations a month" | Main rewrote these keys to "500 free credits to start" while this branch was open. The merge keeps main's credits vocabulary and drops "to start", which asserts the one-time grant this branch removes and which main's own connect dialog already contradicts. Also fixed in passing: `testing/compose/payg/saas-seed.sql` still inserted `free_tier_units_per_cycle`, the pre-V19 column name, so that INSERT had been failing since the rename. ## How to test Backend: ```bash STIRLING_FLAVOR=saas ./gradlew :saas:test spotlessCheck ``` Frontend: ```bash task frontend:typecheck && task frontend:lint && task frontend:format:check ``` New coverage, 10 tests: - `TeamBillingServiceMoreTest` — a past-period stamp reads as a fresh grant, a current stamp reads the stored balance, an unstamped row reads as a fresh grant, the grant follows the Stripe window rather than the calendar month, plus the `remainingForPeriod` rule itself including a future stamp and null/negative balances. - `JobChargeServiceTest` — the first charge of a new period resets and re-stamps, an unstamped row resets, a zero-grant policy still advances the stamp, and a refund crossing a period boundary does not exceed the grant. Manually, against a team whose grant is spent: set `free_units_period_start` back a month (or leave it NULL) and the wallet, the sidebar meter and the entitlement gate should all show a full grant before any job runs. The first billable job should then draw from it and write the reset. Three tests fail on a local Windows run and pass in CI, on files this branch does not touch: `workbenchSession.test.ts`, `notificationActions.test.tsx`, and `:proprietary` `FolderIdentitiesTest.identityAgreesAcrossASymlinkedAliasOfTheDirectory`. Nothing to do here — noted so a local run does not look like a regression. ## Merge order The migration is additive, and Hibernate `ddl-auto=update` will add the column in a dev environment, so either order works locally. Beyond that the schema goes first: Stirling-Tools/Stirling-PDF-SaaS#327 targets `v3` (staging), so it needs to reach an environment before this lands there.
This commit is contained in:
@@ -6097,7 +6097,7 @@ freeTitle = "Unlimited PDF editing"
|
||||
|
||||
[payg.free.hero]
|
||||
barAria = "Free PDFs remaining"
|
||||
capSuffix = "of {{limit}} free PDFs left"
|
||||
capSuffix = "of {{limit}} free PDFs left this month"
|
||||
metaCategories = "Automation · AI · API requests"
|
||||
|
||||
[payg.free.member]
|
||||
@@ -6163,7 +6163,7 @@ leader = "Team owner"
|
||||
member = "Member"
|
||||
|
||||
[payg.signupRequired]
|
||||
body = "Stirling PDF gives every signed-up account 500 free operations, enough to keep most workflows humming without paying a cent. You're currently using Stirling as a guest, which doesn't include billable tools like AI, automations, or hosted processing."
|
||||
body = "Stirling PDF gives every signed-up account 500 free operations a month, enough to keep most workflows humming without paying a cent. You're currently using Stirling as a guest, which doesn't include billable tools like AI, automations, or hosted processing."
|
||||
cancel = "Not now"
|
||||
cta = "Sign up free"
|
||||
subtext = "Creating an account is free and takes a few seconds. No credit card required."
|
||||
@@ -6849,7 +6849,7 @@ name = "Free"
|
||||
[plan.freeLimit]
|
||||
cta = "View Processor Plan"
|
||||
dismiss = "Maybe Later"
|
||||
message = "That's your whole free allowance for automation, AI and the API. Seriously impressive! Keep the momentum going for just pennies a day."
|
||||
message = "That's your whole free allowance for automation, AI and the API this month. Seriously impressive! It resets next month, or keep the momentum going now for just pennies a day."
|
||||
title = "Woah, {{total}} PDFs Processed!"
|
||||
|
||||
[plan.highlights]
|
||||
@@ -7431,17 +7431,17 @@ reachedTitle = "Monthly spend limit reached"
|
||||
title = "Couldn't open Stripe portal"
|
||||
|
||||
[portal.billing.walletMeter]
|
||||
barAria = "Free PDFs remaining"
|
||||
capSuffix_one = "of {{allowance}} free PDF left"
|
||||
capSuffix_other = "of {{allowance}} free PDFs left"
|
||||
barAria = "Free credits remaining"
|
||||
capSuffix_one = "of {{allowance}} free credit left this month"
|
||||
capSuffix_other = "of {{allowance}} free credits left this month"
|
||||
eyebrow = "Processor trial"
|
||||
statusLabel_one = "{{used}} used"
|
||||
statusLabel_other = "{{used}} used"
|
||||
sub = "Use the PDF Editor for free. Pay to process PDFs automatically."
|
||||
title_one = "{{allowance}} free credit to start"
|
||||
title_other = "{{allowance}} free credits to start"
|
||||
titleWithRate_one = "{{allowance}} free credit, then {{rate}} per PDF"
|
||||
titleWithRate_other = "{{allowance}} free credits, then {{rate}} per PDF"
|
||||
title_one = "{{allowance}} free credit every month"
|
||||
title_other = "{{allowance}} free credits every month"
|
||||
titleWithRate_one = "{{allowance}} free credit every month, then {{rate}} per PDF"
|
||||
titleWithRate_other = "{{allowance}} free credits every month, then {{rate}} per PDF"
|
||||
|
||||
[portal.components.billingUnit]
|
||||
approval = "approval"
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from "@app/components/onboarding/onboardingSlideTypes";
|
||||
|
||||
export interface SaasFlowInputs {
|
||||
/** Free-tier wallet with one-time allowance remaining — show the usage meter. */
|
||||
/** Free-tier wallet with allowance remaining this period — show the usage meter. */
|
||||
showUsageSlide: boolean;
|
||||
/** Team leaders only — invited members and anonymous guests skip the team slide. */
|
||||
showTeamSlide: boolean;
|
||||
|
||||
@@ -138,7 +138,7 @@ export function FreeLimitReachedModal({ onClose }: FreeLimitReachedModalProps) {
|
||||
<div className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
|
||||
{t(
|
||||
"plan.freeLimit.message",
|
||||
"That's your whole free allowance for automation, AI and the API. Seriously impressive! Keep the momentum going for just pennies a day.",
|
||||
"That's your whole free allowance for automation, AI and the API this month. Seriously impressive! It resets next month, or keep the momentum going now for just pennies a day.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,14 +9,13 @@
|
||||
* watermarks, compression — are unmetered, no matter where they're triggered
|
||||
* from. The distinction is the <em>type of work</em> (manual tool vs
|
||||
* automation / AI / API), not where the click happens, because automation and
|
||||
* AI also have UI surfaces. The one-time free grant (default 500) applies
|
||||
* <em>only</em> to the three billable categories — it is a lifetime allowance,
|
||||
* not a monthly one, and a team keeps any unused portion after subscribing.
|
||||
* AI also have UI surfaces. The free grant applies <em>only</em> to the three
|
||||
* billable categories, and resets each billing period.
|
||||
*
|
||||
* <p>Layout: a slim <b>Editor plan</b> card (always-free tools only — no dates,
|
||||
* no metered split) on top, then a single <b>Processor plan</b> card that
|
||||
* two-columns the upgrade pitch + benefits (left) against the one-time free
|
||||
* meter stacked over the call-to-action (right).
|
||||
* two-columns the upgrade pitch + benefits (left) against the free-grant meter
|
||||
* stacked over the call-to-action (right).
|
||||
*
|
||||
* <p>Two variants:
|
||||
* - {@link PaygFreeLeader} — the right column's CTA opens the upgrade modal.
|
||||
@@ -47,8 +46,6 @@ import {
|
||||
type FreeSnapshot,
|
||||
} from "@app/components/shared/config/configSections/usageMeters";
|
||||
|
||||
// ─── Editor plan card (always-free tools only) ────────────────────────────
|
||||
|
||||
interface EditorPlanCardProps {
|
||||
/** Role pill text on the right. */
|
||||
pill: string;
|
||||
@@ -58,8 +55,8 @@ interface EditorPlanCardProps {
|
||||
|
||||
/**
|
||||
* The top card: the free Editor plan. Manual tools only, no billing window —
|
||||
* the one-time grant lives in the Processor card below, so there's no period
|
||||
* to show here.
|
||||
* the metered grant lives in the Processor card below, so there's no period to
|
||||
* show here.
|
||||
*/
|
||||
function EditorPlanCard({ pill, leader }: EditorPlanCardProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -93,8 +90,6 @@ function EditorPlanCard({ pill, leader }: EditorPlanCardProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Processor plan card (two-column: pitch + benefits | meter + CTA) ──────
|
||||
|
||||
interface ProcessorCardProps {
|
||||
snap: FreeSnapshot;
|
||||
/** Leaders get the live CTA; members get the ask-owner note. */
|
||||
@@ -207,8 +202,6 @@ function ProcessorCard({ snap, isLeader, onTurnOn }: ProcessorCardProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Free LEADER ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface PaygFreeLeaderProps {
|
||||
/**
|
||||
* Called when the user finishes the {@link UpgradeModal} checkout flow.
|
||||
@@ -265,8 +258,6 @@ function PaygFreeLeaderInner({ onUpgraded }: PaygFreeLeaderProps = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Free MEMBER ──────────────────────────────────────────────────────────
|
||||
|
||||
function PaygFreeMemberInner() {
|
||||
useRenderCount("PaygFreeMember");
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -69,10 +69,9 @@ interface UpgradeModalProps {
|
||||
/** ISO 4217 currency code for the cap input. Default USD. */
|
||||
currency?: "USD" | "EUR" | "GBP";
|
||||
/**
|
||||
* The team's one-time free grant in documents — the real {@code
|
||||
* The team's free grant in documents per billing period — the real {@code
|
||||
* wallet.freeAllowance}, threaded from the free-leader view so the step copy
|
||||
* quotes the backend's number instead of a hardcoded one. A lifetime grant,
|
||||
* not a monthly one.
|
||||
* quotes the backend's number instead of a hardcoded one.
|
||||
*/
|
||||
freeLimit: number;
|
||||
/**
|
||||
|
||||
@@ -17,12 +17,10 @@ import {
|
||||
import "@app/components/shared/config/configSections/Payg.css";
|
||||
import "@app/components/shared/config/configSections/PaygFree.css";
|
||||
|
||||
// ─── One-time free grant meter ──────────────────────────────────────────────
|
||||
|
||||
export interface FreeSnapshot {
|
||||
/** One-time free documents used so far (grant − remaining). */
|
||||
/** Free documents used so far this period (grant − remaining). */
|
||||
billableUsed: number;
|
||||
/** The team's one-time free grant size in documents. */
|
||||
/** The team's free grant size in documents, per billing period. */
|
||||
billableLimit: number;
|
||||
}
|
||||
|
||||
@@ -64,9 +62,11 @@ export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) {
|
||||
pct={pct}
|
||||
barLabel={t("payg.free.hero.barAria", "Free PDFs remaining")}
|
||||
figure={remaining.toLocaleString()}
|
||||
capSuffix={t("payg.free.hero.capSuffix", "of {{limit}} free PDFs left", {
|
||||
limit: snap.billableLimit.toLocaleString(),
|
||||
})}
|
||||
capSuffix={t(
|
||||
"payg.free.hero.capSuffix",
|
||||
"of {{limit}} free PDFs left this month",
|
||||
{ limit: snap.billableLimit.toLocaleString() },
|
||||
)}
|
||||
statusLabel={stateLabel}
|
||||
meta={
|
||||
<span>
|
||||
@@ -77,8 +77,6 @@ export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Monthly spend-cap meter ────────────────────────────────────────────────
|
||||
|
||||
export interface SpendCapSnapshot {
|
||||
/** Money spent so far this billing period, in major currency units. */
|
||||
spent: number;
|
||||
@@ -106,8 +104,8 @@ export function spendCapSnapshotFromWallet(
|
||||
}
|
||||
|
||||
/**
|
||||
* Sibling of {@link FreeMeterPanel} for the money cap rather than the one-time
|
||||
* free grant. Shares the same bar/status styling and the cap-state labels
|
||||
* Sibling of {@link FreeMeterPanel} for the money cap rather than the free
|
||||
* grant. Shares the same bar/status styling and the cap-state labels
|
||||
* ({@code payg.state.*}) used by the Plan hero, so it reads as the same meter.
|
||||
*/
|
||||
export function SpendCapMeterPanel({ snap }: { snap: SpendCapSnapshot }) {
|
||||
@@ -149,8 +147,6 @@ export function SpendCapMeterPanel({ snap }: { snap: SpendCapSnapshot }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Prepaid bundle capacity meter ──────────────────────────────────────────
|
||||
|
||||
export interface PrepaidSnapshot {
|
||||
/** Prepaid units still available across the team's in-term pools. */
|
||||
remaining: number;
|
||||
|
||||
@@ -13,10 +13,8 @@ function toCredits(
|
||||
freeRemaining: number,
|
||||
freeAllowance: number,
|
||||
): CachedCredits {
|
||||
// Free teams only. The grant is a lifetime pool that survives subscribing, so
|
||||
// a paying team would otherwise sit on a permanent "0 of 500" in red while
|
||||
// nothing is wrong. Plan draws the same line — subscribed teams get the
|
||||
// spend-vs-cap meter there, and admins get usage in the processor.
|
||||
// Free teams only: a payer's headline number is spend against cap, and a
|
||||
// draining free meter beside a live invoice reads as a problem.
|
||||
if (status === "subscribed") return null;
|
||||
return { remaining: freeRemaining, total: freeAllowance };
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code 402 FEATURE_DEGRADED} — an authenticated (JWT/web) team hit a
|
||||
* billable feature it no longer has: a free team that spent its one-time
|
||||
* billable feature it no longer has: a free team that spent this period's
|
||||
* allowance, or a subscribed team over its monthly spending cap. Which
|
||||
* one is told by the {@code subscribed} field on the body.</li>
|
||||
* <li>{@code 402 PAYG_LIMIT_REACHED} — same situation reached via an API key
|
||||
|
||||
@@ -14,7 +14,7 @@ type Story = StoryObj<typeof SpendThisMonthCard>;
|
||||
/** Actual spend + the Enterprise upsell tacked onto the foot. */
|
||||
export const Default: Story = { args: { wallet: subscribedWallet } };
|
||||
|
||||
/** Subscribed but still holding leftover lifetime free grant — shows the free-remaining note. */
|
||||
/** Subscribed with this period's free grant part-spent — shows the free-remaining note. */
|
||||
export const WithFreeRemaining: Story = {
|
||||
args: { wallet: { ...subscribedWallet, freeRemaining: 380 } },
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ interface Props {
|
||||
}
|
||||
|
||||
/**
|
||||
* The free Processor-trial meter — "X of N free PDFs left" against the one-time
|
||||
* The free Processor-trial meter — "X of N free PDFs left" against this period's
|
||||
* grant, with what has been used alongside as the status badge. The bar shows what
|
||||
* is left, so it drains towards empty as the grant is spent.
|
||||
* Uses the shared {@link MeterBar} (same `paygf-meter` structure as the
|
||||
@@ -41,7 +41,7 @@ export function WalletMeter({ wallet, unsynced, action }: Props) {
|
||||
rate != null
|
||||
? t(
|
||||
"portal.billing.walletMeter.titleWithRate",
|
||||
"Process {{allowance}} PDFs free, then {{rate}}/PDF",
|
||||
"{{allowance}} free credits every month, then {{rate}} per PDF",
|
||||
{
|
||||
count: wallet.freeAllowance,
|
||||
allowance: wallet.freeAllowance.toLocaleString(),
|
||||
@@ -50,7 +50,7 @@ export function WalletMeter({ wallet, unsynced, action }: Props) {
|
||||
)
|
||||
: t(
|
||||
"portal.billing.walletMeter.title",
|
||||
"Process {{allowance}} PDFs free",
|
||||
"{{allowance}} free credits every month",
|
||||
{
|
||||
count: wallet.freeAllowance,
|
||||
allowance: wallet.freeAllowance.toLocaleString(),
|
||||
@@ -80,12 +80,12 @@ export function WalletMeter({ wallet, unsynced, action }: Props) {
|
||||
pct={pct}
|
||||
barLabel={t(
|
||||
"portal.billing.walletMeter.barAria",
|
||||
"Free PDFs remaining",
|
||||
"Free credits remaining",
|
||||
)}
|
||||
figure={remaining.toLocaleString()}
|
||||
capSuffix={t(
|
||||
"portal.billing.walletMeter.capSuffix",
|
||||
"of {{allowance}} free PDFs left",
|
||||
"of {{allowance}} free credits left this month",
|
||||
{
|
||||
count: wallet.freeAllowance,
|
||||
allowance: wallet.freeAllowance.toLocaleString(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Wallet } from "@portal/api/billing";
|
||||
|
||||
/** Linked, on the one-time free grant — leader view. Override per story. */
|
||||
/** Linked, on the free monthly grant — leader view. Override per story. */
|
||||
export const freeWallet: Wallet = {
|
||||
teamId: 42,
|
||||
status: "free",
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
*
|
||||
* - `unlinked` — no SaaS account linked. Billable features render a
|
||||
* "link to unlock" affordance.
|
||||
* - `linked-free` — linked, running on the one-time free grant (500 PDFs).
|
||||
* - `linked-free` — linked, running on the per-period free grant.
|
||||
* - `linked-subscribed` — linked with a live PAYG subscription.
|
||||
*
|
||||
* The portal admin establishes the link by signing in to the SaaS Supabase
|
||||
|
||||
@@ -55,7 +55,7 @@ describe("useFreeCreditsSummary (self-hosted) — wallet behind the link gate",
|
||||
});
|
||||
|
||||
it("hides the meter once the team subscribes", async () => {
|
||||
// The grant is a lifetime pool that survives subscribing, so a paying team
|
||||
// A paying team's headline number is spend against its cap, so a paying team
|
||||
// would otherwise sit on a spent meter forever.
|
||||
fetchWallet.mockResolvedValue({
|
||||
status: "subscribed",
|
||||
|
||||
@@ -26,9 +26,8 @@ import { type NavFooterCredits } from "@app/components/shared/navFooter/NavFoote
|
||||
* since the wallet lives in the cloud even when the instance doesn't. Gated on
|
||||
* linkage: an unlinked instance has no wallet to read.
|
||||
*
|
||||
* Free teams only, matching the editor and the Plan page. The grant is a
|
||||
* lifetime pool that survives subscribing, so a paying team would otherwise sit
|
||||
* on a permanent "0 of 500" in red; their usage lives on Usage & Billing.
|
||||
* Free teams only, matching the editor and the Plan page: a payer's headline
|
||||
* number is spend against cap, and their usage lives on Usage & Billing.
|
||||
*/
|
||||
export function useFreeCreditsSummary(): NavFooterCredits | null {
|
||||
const { isLinked } = useLink();
|
||||
|
||||
@@ -50,9 +50,9 @@ export function formatMoneyMajor(
|
||||
|
||||
/**
|
||||
* Paid PDFs a monthly cap buys — mirror of the backend's {@code docCapForMoney}:
|
||||
* floor(capMinor / rate). The one-time free grant is a separate lifetime pool and
|
||||
* is NOT added here. Returns null when there's no cap or no resolvable rate (the
|
||||
* caller hides the estimate).
|
||||
* floor(capMinor / rate). The free grant is a separate per-period pool and is NOT
|
||||
* added here. Returns null when there's no cap or no resolvable rate (the caller
|
||||
* hides the estimate).
|
||||
*/
|
||||
export function docCapForMoney(
|
||||
capUsdMajor: number | null,
|
||||
@@ -88,8 +88,6 @@ export function formatPeriodDate(
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Prepaid bundle pricing (run-based brain) ───────────────────────────────
|
||||
|
||||
/**
|
||||
* "12 months for the price of 10" — months granted vs paid. Mirrors the Stripe
|
||||
* coupon (10/12 off), so the calculator's live estimate matches the amount charged.
|
||||
|
||||
@@ -40,13 +40,13 @@ export interface Wallet {
|
||||
/** ISO yyyy-mm-dd. Stripe period when subscribed; calendar month when free. */
|
||||
billingPeriodStart: string;
|
||||
billingPeriodEnd: string;
|
||||
/** Free grant used (free teams) or documents processed this period (subscribed). */
|
||||
/** Free grant used this period (free teams) or documents processed this period (subscribed). */
|
||||
billableUsed: number;
|
||||
/** Document ceiling for the window; null when subscribed-uncapped. */
|
||||
billableLimit: number | null;
|
||||
/** One-time free grant size — a lifetime pool that survives subscribing. */
|
||||
/** Free grant size per billing period; unused units don't carry over. */
|
||||
freeAllowance: number;
|
||||
/** Free grant still available; 0 = exhausted. */
|
||||
/** Free grant left in this period; 0 = exhausted. */
|
||||
freeRemaining: number;
|
||||
/** Paid per-document rate in minor units (may be fractional); null = unknown (render "unknown", never substitute). */
|
||||
pricePerDocMinor: number | null;
|
||||
|
||||
@@ -93,7 +93,7 @@ export default function SignupRequiredBootstrap() {
|
||||
<Text>
|
||||
{t(
|
||||
"payg.signupRequired.body",
|
||||
"Stirling PDF gives every signed-up account 500 free operations — enough to keep most workflows humming without paying a cent. You're currently using Stirling as a guest, which doesn't include billable tools like AI, automations, or hosted processing.",
|
||||
"Stirling PDF gives every signed-up account 500 free operations a month, enough to keep most workflows humming without paying a cent. You're currently using Stirling as a guest, which doesn't include billable tools like AI, automations, or hosted processing.",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
|
||||
Reference in New Issue
Block a user