Compare commits

..

2 Commits

Author SHA1 Message Date
d78f9f2696 Phase8: Auto bill credit card
All checks were successful
Build and Push / build (push) Successful in 1m44s
2026-05-28 21:49:59 +02:00
3fe3597553 Phase8: Auto bill credit card
All checks were successful
Build and Push / build (push) Successful in 1m48s
2026-05-28 21:29:15 +02:00
13 changed files with 212 additions and 224 deletions

View File

@@ -4,7 +4,7 @@ import { redirect } from "next/navigation";
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
import { BackLink } from "@/components/ui/back-link";
import { listTenants } from "@/lib/k8s";
import { listActiveTenantRequestsByOrgId, getOrgBilling } from "@/lib/db";
import { listActiveTenantRequestsByOrgId, getOrgBilling, getPlatformPricing } from "@/lib/db";
import { personalAccountAtCapacity } from "@/lib/personal-org";
/**
@@ -55,7 +55,10 @@ export default async function NewInstancePage() {
}
const t = await getTranslations("dashboard");
const orgBilling = await getOrgBilling(user.orgId);
const [orgBilling, pricing] = await Promise.all([
getOrgBilling(user.orgId),
getPlatformPricing(),
]);
const hasOrgBilling = orgBilling !== null;
return (
@@ -77,6 +80,7 @@ export default async function NewInstancePage() {
userEmail={user.email}
hasOrgBilling={hasOrgBilling}
existingOrgBilling={orgBilling}
setupFeeChf={pricing.tenantSetupFeeChf}
/>
</div>
</div>

View File

@@ -6,6 +6,7 @@ import {
listActiveTenantRequestsByOrgId,
syncProvisioningStatuses,
getOrgBilling,
getPlatformPricing,
} from "@/lib/db";
import {
listVisibleTenants,
@@ -192,6 +193,7 @@ export default async function DashboardPage() {
// component.
const orgBilling = await getOrgBilling(user.orgId);
const hasOrgBilling = orgBilling !== null;
const platformPricing = await getPlatformPricing();
// Pending requests that don't yet have a tenant CR. Once the CR
// exists, the tenant card carries the live phase, so a separate
@@ -318,6 +320,7 @@ export default async function DashboardPage() {
userEmail={user.email}
hasOrgBilling={hasOrgBilling}
existingOrgBilling={orgBilling}
setupFeeChf={platformPricing.tenantSetupFeeChf}
/>
</div>
</div>

View File

@@ -1,51 +1,27 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser } from "@/lib/session";
import { setAutoChargeEnabled } from "@/lib/db";
import { safeError } from "@/lib/errors";
/**
* POST /api/billing/auto-charge
* POST /api/billing/auto-charge — RETIRED.
*
* Phase 9. Toggle the auto_charge_enabled flag on the caller's
* org. The body is `{ enabled: boolean }`.
* Auto-pay is no longer a customer-toggleable setting. A saved
* card on file is the consent to auto-bill; customers manage their
* card via update/remove on /settings/billing, nothing else. The
* auto_charge_enabled flag is now an admin-only pause used during
* disputes, set from /admin/billing/orgs.
*
* When OFF: invoices issued for this org won't trigger an
* auto-charge against the saved card. The customer pays
* manually (or admin marks paid) — same flow as a bank-transfer
* customer.
*
* When ON: future invoice issuance attempts the auto-charge.
* No effect if there's no saved card on file.
*
* Idempotent: setting OFF on an already-OFF flag is a no-op
* (same outcome).
* This route is kept as an explicit 410 (Gone) so any stale client
* that still POSTs here fails loudly rather than silently toggling
* a flag the customer shouldn't control. The old behaviour lived
* here through Phase 9b-2.
*/
const bodySchema = z.object({
enabled: z.boolean(),
});
export async function POST(request: Request) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json().catch(() => ({}));
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid request", details: parsed.error.flatten() },
{ status: 400 }
);
}
try {
await setAutoChargeEnabled(user.orgId, parsed.data.enabled);
return NextResponse.json({ enabled: parsed.data.enabled });
} catch (e) {
return NextResponse.json(
{ error: safeError(e, "Failed to update auto-charge setting") },
{ status: 500 }
);
}
export async function POST() {
return NextResponse.json(
{
error:
"Auto-pay can no longer be disabled. A saved card is required for service. " +
"Contact support if you need to switch to bank-transfer billing.",
code: "auto_pay_not_toggleable",
},
{ status: 410 }
);
}

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { getSessionUser, canMutate } from "@/lib/session";
import {
getInvoiceById,
getTenantRequestById,
updateTenantRequestStatus,
updateTenantRequestEditableFields,
@@ -9,6 +10,8 @@ import { encryptSecrets } from "@/lib/crypto";
import { setTenantAnnotation } from "@/lib/k8s";
import { onboardingSchema } from "@/lib/validation";
import { safeError } from "@/lib/errors";
import { refundInvoice, RefundNotAllowedError } from "@/lib/billing";
import type { SessionUser, TenantRequest } from "@/types";
/**
* Customer-side controls for a single tenant_request row.
@@ -29,7 +32,7 @@ async function loadAuthorized(
id: string
): Promise<
| { error: NextResponse }
| { req: Awaited<ReturnType<typeof getTenantRequestById>>; }
| { req: TenantRequest; user: SessionUser }
> {
const user = await getSessionUser();
if (!user) {
@@ -55,7 +58,7 @@ async function loadAuthorized(
error: NextResponse.json({ error: "Not found" }, { status: 404 }),
};
}
return { req: tr };
return { req: tr, user };
}
/**
@@ -93,6 +96,50 @@ export async function DELETE(
try {
await updateTenantRequestStatus(id, "cancelled");
// Phase 9b: a 'pending' provision request has already had its
// setup fee charged (the order-time Checkout completed before
// the webhook flipped it to 'pending'). Cancelling it must
// refund that payment, exactly as an admin rejection does.
// Resume requests never carry a setup_invoice_id, so this only
// fires for provision orders. Best-effort: a refund failure is
// logged + surfaced but doesn't block the cancellation (admin
// can refund manually from the invoice page).
let refund: { attempted: boolean; succeeded: boolean; error?: string } = {
attempted: false,
succeeded: false,
};
if (tr.requestType === "provision" && tr.setupInvoiceId) {
refund.attempted = true;
try {
const inv = await getInvoiceById(tr.setupInvoiceId);
if (!inv) {
throw new Error(`Linked setup invoice ${tr.setupInvoiceId} not found`);
}
const remaining =
Math.round((inv.totalChf - (inv.refundedTotalChf ?? 0)) * 100) / 100;
if (remaining <= 0) {
refund.succeeded = true; // nothing left to refund
} else {
await refundInvoice({
invoiceId: tr.setupInvoiceId,
amountChf: remaining,
reason: "Order cancelled by customer",
refundedBy: loaded.user!.id,
});
refund.succeeded = true;
}
} catch (e: any) {
refund.error =
e instanceof RefundNotAllowedError
? e.message
: (e?.message ?? "refund failed");
console.error(
`Setup-fee refund failed for cancelled request ${id} (invoice ${tr.setupInvoiceId}):`,
e
);
}
}
// Customer cancels their own pending resume request: clear the
// operator-side annotation so the 60-day TTL resumes counting.
// Best-effort — the operator handles missing annotation gracefully.
@@ -111,7 +158,7 @@ export async function DELETE(
}
}
return NextResponse.json({ message: "Request cancelled.", id });
return NextResponse.json({ message: "Request cancelled.", id, refund });
} catch (e: any) {
console.error("Failed to cancel request:", e);
return NextResponse.json(

View File

@@ -4,7 +4,6 @@ import {
createTenantRequest,
createTenantRequestPendingPayment,
deletePendingPaymentRequest,
getOrgBillingConfig,
getTenantRequestById,
listTenantRequestsByOrgId,
listActiveTenantRequestsByOrgId,
@@ -27,7 +26,7 @@ import {
createSetupFeeCheckoutSession,
ensureStripeCustomerForOrg,
} from "@/lib/stripe";
import { createTenantSetupFeeInvoice } from "@/lib/billing";
import { createTenantSetupFeeInvoice, voidInvoice } from "@/lib/billing";
import { deriveTenantName } from "@/lib/tenant-naming";
import type {
InvoiceBillingSnapshot,
@@ -417,27 +416,6 @@ export async function POST(request: Request) {
);
}
// Phase 9b: enforce auto-pay before accepting an order. If the
// org has no saved card OR has explicitly disabled auto-charge,
// the order can't proceed — return 402 with a link to the
// settings page where they can set up auto-pay. The wizard
// surfaces this as a friendly redirect rather than an error.
const cfg = await getOrgBillingConfig(user.orgId);
const hasSavedCard = !!cfg.stripeDefaultPaymentMethodId;
const autoChargeOn = cfg.autoChargeEnabled !== false;
if (!hasSavedCard || !autoChargeOn) {
return NextResponse.json(
{
error:
"Auto-pay must be set up before ordering a new instance. " +
"Please save a card and ensure auto-pay is enabled on /settings/billing.",
code: "auto_pay_required",
redirectTo: "/settings/billing",
},
{ status: 402 }
);
}
// Look up the setup fee. If it's 0 we skip the Checkout flow
// entirely and create a normal pending request (same as the
// pre-Phase-9b behaviour).
@@ -522,35 +500,33 @@ export async function POST(request: Request) {
tenantRequest.id
);
// Build the billing snapshot from the org's address (already
// fetched above for the wizard's billing-address resolution).
// The snapshot is what the invoice + Stripe customer use.
//
// orgBilling MUST exist here: the auto-pay pre-check above
// requires a saved Stripe PaymentMethod, which can only be
// created via ensureStripeCustomerForOrg, which requires
// org_billing. If it's missing the system is in an inconsistent
// state we shouldn't paper over.
if (!orgBilling) {
// Re-fetch orgBilling here: the variable at the top of POST was
// captured BEFORE the upsertOrgBilling call upstream (which fires
// when the wizard collected the address on first onboarding). For
// a brand-new user that initial fetch returned null; only by
// re-fetching now do we get the row we just wrote. Existing
// customers get the same orgBilling back either way.
const billingForOrder = await getOrgBilling(user.orgId);
if (!billingForOrder) {
console.error(
`Paid-fee onboarding path reached without org_billing for org ${user.orgId} — auto-pay pre-check should have prevented this.`
`Paid-fee onboarding path: no org_billing for org ${user.orgId} even after upsert — wizard did not collect address?`
);
await deletePendingPaymentRequest(tenantRequest.id).catch(() => undefined);
return NextResponse.json(
{ error: "Billing record missing. Please re-save your billing details on /settings/billing." },
{ error: "Billing record missing. Please re-save your billing details." },
{ status: 500 }
);
}
const billingSnapshot: InvoiceBillingSnapshot = {
companyName: orgBilling.companyName,
contactName: orgBilling.contactName ?? null,
streetAddress: orgBilling.streetAddress,
postalCode: orgBilling.postalCode,
city: orgBilling.city,
country: orgBilling.country,
vatNumber: orgBilling.vatNumber ?? null,
billingEmail: orgBilling.billingEmail,
notes: orgBilling.notes ?? null,
companyName: billingForOrder.companyName,
contactName: billingForOrder.contactName ?? null,
streetAddress: billingForOrder.streetAddress,
postalCode: billingForOrder.postalCode,
city: billingForOrder.city,
country: billingForOrder.country,
vatNumber: billingForOrder.vatNumber ?? null,
billingEmail: billingForOrder.billingEmail,
notes: billingForOrder.notes ?? null,
};
// Locale for the invoice + PDF — pick from the org's country
@@ -611,7 +587,24 @@ export async function POST(request: Request) {
checkoutUrl = url;
} catch (e) {
console.error("Failed to create setup-fee Checkout session:", e);
// Roll back the pending_payment row.
// Roll back BOTH the pending_payment row and the setup invoice
// we already created. The invoice was issued in 'open' status
// but no payment will ever arrive (Checkout never started), so
// void it to keep the ledger clean — an open invoice with no
// route to payment would otherwise linger and show up in
// arrears reports. Void (not delete) preserves the audit trail
// and the void reason. Best-effort: a void failure is logged
// but doesn't change the 500 we return.
await voidInvoice({
invoiceId: setupInvoice.id,
reason: "Order abandoned before payment (Checkout could not be started)",
voidedBy: user.id,
}).catch((ve) =>
console.error(
`Failed to void orphaned setup invoice ${setupInvoice.id}:`,
ve
)
);
await deletePendingPaymentRequest(tenantRequest.id).catch(() => undefined);
return NextResponse.json(
{ error: "Failed to start payment. Please try again." },

View File

@@ -26,6 +26,11 @@ interface OnboardingFlowProps {
* validation skip when the billing step was skipped.
*/
existingOrgBilling?: OrgBilling | null;
/**
* Phase 9b: platform setup fee (net CHF) shown on the review
* step. Forwarded straight to the wizard.
*/
setupFeeChf?: number | null;
/**
* Bug 6: when present, the wizard is rendered in edit mode against
* the given pending request. See `OnboardingWizard` for the full
@@ -53,6 +58,7 @@ export function OnboardingFlow({
userEmail,
hasOrgBilling,
existingOrgBilling,
setupFeeChf,
editingRequest,
}: OnboardingFlowProps) {
const router = useRouter();
@@ -64,6 +70,7 @@ export function OnboardingFlow({
userEmail={userEmail}
hasOrgBilling={hasOrgBilling}
existingOrgBilling={existingOrgBilling}
setupFeeChf={setupFeeChf}
editingRequest={editingRequest}
onComplete={() => {
// Navigate back to /dashboard and re-fetch on the server. The

View File

@@ -108,6 +108,14 @@ interface WizardProps {
* billingAddress snapshot).
*/
existingOrgBilling?: OrgBilling | null;
/**
* Phase 9b: the platform's current tenant setup fee (net CHF,
* before VAT). Shown on the review step so the customer sees how
* much they're about to be charged before being sent to Stripe.
* Null/0 means no setup fee — the review notice is suppressed and
* the order skips the Checkout redirect (handled server-side).
*/
setupFeeChf?: number | null;
/**
* Bug 6: when present, the wizard renders in "edit" mode — fields
* are pre-populated from the request, the SOUL.md auto-fetch is
@@ -147,6 +155,7 @@ export function OnboardingWizard({
userEmail,
hasOrgBilling,
existingOrgBilling,
setupFeeChf,
editingRequest,
onComplete,
}: WizardProps) {
@@ -183,11 +192,6 @@ export function OnboardingWizard({
const [step, setStep] = useState<Step>(isEditing ? "configure" : "welcome");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
// Phase 9b: 402 from the onboarding endpoint indicates the org
// needs to set up auto-pay before ordering. We render a tailored
// error block with a clickable link to /settings/billing rather
// than the generic red message.
const [autoPayRequired, setAutoPayRequired] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
// In edit mode we already have soulMd/agentsMd from the request;
// skip the workspace-defaults round trip that would overwrite them.
@@ -435,7 +439,6 @@ export function OnboardingWizard({
setSubmitting(true);
setError("");
setAutoPayRequired(false);
try {
// Build secrets payload — only for packages that require them
@@ -482,19 +485,6 @@ export function OnboardingWizard({
}),
});
// Phase 9b: 402 means the org needs to set up auto-pay
// before ordering. Surface a friendly message with a link to
// /settings/billing instead of the generic submission error.
if (res.status === 402) {
const data = await res.json().catch(() => ({}));
if (data?.code === "auto_pay_required") {
setAutoPayRequired(true);
setError(t("autoPayRequiredError"));
return;
}
throw new Error(data.error || "Submission failed");
}
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Submission failed");
@@ -755,7 +745,9 @@ export function OnboardingWizard({
className={`border rounded-lg overflow-hidden transition-colors ${
isSelected
? "border-accent bg-accent/5"
: "border-border bg-surface-2"
: pkg.recommended
? "border-accent/40 bg-accent/[0.02]"
: "border-border bg-surface-2"
}`}
>
{/* Toggle row */}
@@ -774,6 +766,11 @@ export function OnboardingWizard({
>
{pkg.name}
</span>
{pkg.recommended && (
<span className="ml-2 text-[10px] font-semibold uppercase tracking-wide text-accent bg-accent/10 border border-accent/30 rounded-full px-1.5 py-0.5">
{tPkg("recommended")}
</span>
)}
{pkg.requiresSecrets && (
<span className="ml-1.5 text-[10px] text-text-muted">
({tPkg("requiresApiKey")})
@@ -795,8 +792,16 @@ export function OnboardingWizard({
</div>
</button>
{/* Inline credential inputs — expand when selected + requires secrets */}
{isSelected && pkg.requiresSecrets && (
{/* Inline expansion when selected — shows
instructions (if any), credential inputs
(if requiresSecrets), and the disclaimer
checkbox (if any). Threema for example
has no customer-entered secrets but has
instructions + a disclaimer to accept. */}
{isSelected &&
(pkg.requiresSecrets ||
pkg.instructionsKey ||
pkg.disclaimerKey) && (
<div className="border-t border-border px-3 py-3 space-y-3 bg-surface-1/50">
{pkg.instructionsKey && (
<div className="bg-surface-2 border border-border rounded-lg p-3 text-xs text-text-secondary leading-relaxed whitespace-pre-line">
@@ -1065,28 +1070,6 @@ export function OnboardingWizard({
</p>
</FieldWithError>
)}
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("billingNotes")}
</label>
<textarea
value={config.billingNotes}
onChange={(e) =>
setConfig((prev) => ({
...prev,
billingNotes: e.target.value,
}))
}
rows={3}
placeholder={t(
isPersonal
? "billingNotesPlaceholderPersonal"
: "billingNotesPlaceholder"
)}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors resize-y"
/>
</div>
</div>
<div className="flex justify-between mt-6">
@@ -1248,48 +1231,39 @@ export function OnboardingWizard({
value={userEmail || ""}
mono
/>
{config.billingNotes.trim().length > 0 && (
<ReviewRow
label={t("billingNotes")}
value={
<span className="text-text-primary whitespace-pre-wrap text-right">
{config.billingNotes}
</span>
}
/>
)}
</div>
<p className="text-xs text-text-muted">{t("confirmNote")}</p>
{/* Phase 9b: order-time setup-fee notice. The exact
amount is determined server-side at submit (the
platform_pricing table is the authority), but the
customer should know that *some* charge happens on
the next click. Wording is neutral about the amount
— we don't want to mis-display a stale figure. */}
<div className="text-xs rounded-md border border-accent/30 bg-accent/10 text-text-secondary px-3 py-3 mt-4">
<strong className="block text-text-primary mb-1">
{t("setupFeeNoticeHeading")}
</strong>
{t("setupFeeNoticeBody")}
</div>
{/* Phase 9b: order-time setup-fee notice + amount. The
figure shown is the net platform fee (before VAT);
VAT is added server-side based on the billing
country. We show "+ VAT" rather than a computed
gross to avoid mis-displaying a country-dependent
total. If setupFeeChf is null/0, no charge happens
and the whole block is suppressed. */}
{typeof setupFeeChf === "number" && setupFeeChf > 0 && (
<div className="text-xs rounded-md border border-accent/30 bg-accent/10 text-text-secondary px-3 py-3 mt-4">
<strong className="block text-text-primary mb-1">
{t("setupFeeNoticeHeading")}
</strong>
<div className="flex items-baseline justify-between mb-2 pb-2 border-b border-accent/20">
<span>{t("setupFeeAmountLabel")}</span>
<span className="text-sm font-semibold text-text-primary">
CHF {setupFeeChf.toFixed(2)}{" "}
<span className="text-[10px] font-normal text-text-muted">
{t("setupFeePlusVat")}
</span>
</span>
</div>
{t("setupFeeNoticeBody")}
</div>
)}
</div>
{error && (
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mt-4">
{error}
{autoPayRequired && (
<>
{" "}
<a
href="/settings/billing"
className="underline font-medium text-red-300 hover:text-red-200"
>
{t("autoPaySetupLink")}
</a>
</>
)}
</div>
)}

View File

@@ -57,7 +57,7 @@ export function SavedCardSection({
const t = useTranslations("settingsBilling");
const router = useRouter();
const searchParams = useSearchParams();
const [busy, setBusy] = useState<null | "setup" | "remove" | "toggle">(null);
const [busy, setBusy] = useState<null | "setup" | "remove">(null);
const [error, setError] = useState("");
// Refresh + clean the URL when Stripe redirects back. Stripe's
@@ -109,25 +109,6 @@ export function SavedCardSection({
}
};
const toggleAutoCharge = async () => {
setError("");
setBusy("toggle");
try {
const res = await fetch("/api/billing/auto-charge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: !autoChargeOn }),
});
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
router.refresh();
} catch (e: any) {
setError(e.message);
} finally {
setBusy(null);
}
};
// Empty state — no card on file.
if (!hasCard) {
return (
@@ -262,17 +243,6 @@ export function SavedCardSection({
? t("savedCardRedirecting")
: t("savedCardUpdateBtn")}
</button>
<button
onClick={toggleAutoCharge}
disabled={busy !== null}
className="px-3 py-1.5 rounded-md border border-border text-sm disabled:opacity-50 hover:bg-surface-3"
>
{busy === "toggle"
? t("saving")
: autoChargeOn
? t("savedCardDisableAutoChargeBtn")
: t("savedCardEnableAutoChargeBtn")}
</button>
<button
onClick={removeCard}
disabled={busy !== null}

View File

@@ -76,6 +76,14 @@ export interface PackageDef {
* admin does the manual work, then approves.
*/
requiresManualSetup?: boolean;
/**
* Phase 9b: when true, the wizard visually highlights this package
* as recommended (a badge + accent border) without pre-selecting
* it. Used for the Threema channel — we want customers to choose
* Threema as their messaging surface when possible, but the choice
* stays opt-in.
*/
recommended?: boolean;
}
export const PACKAGE_CATALOG: PackageDef[] = [
@@ -173,6 +181,7 @@ export const PACKAGE_CATALOG: PackageDef[] = [
instructionsKey: "packages.threema.instructions",
disclaimerKey: "packages.threema.disclaimer",
category: "channel",
recommended: true,
},
// -------------------------------------------------------------------------
@@ -231,7 +240,6 @@ export const PACKAGE_CATALOG: PackageDef[] = [
},
{
id: "gog",
requiresManualSetup: true,
name: "Google Workspace (Gog)",
descriptionKey: "packages.gog.description",
requiresSecrets: true,
@@ -334,9 +342,11 @@ export const CHANNEL_PACKAGE_IDS: string[] = PACKAGE_CATALOG
* audio spend on every inbound voice note (Whisper STT) and every
* outbound reply (kani-tts / kokoro-fastapi via LiteLLM). Opt-in keeps
* cost predictable for tenants who don't intend to use voice channels.
*
* Phase 9b revision: nothing is pre-enabled. New tenants start with a
* blank slate — the customer opts into exactly what they want. The
* Threema channel is flagged `recommended` (see PACKAGE_CATALOG) so
* the wizard highlights it, since we want customers to use Threema as
* their channel when possible — but it's still opt-in, not auto-on.
*/
export const DEFAULT_PACKAGE_IDS: string[] = [
"core-heartbeat",
"core-cron",
"core-active-memory",
];
export const DEFAULT_PACKAGE_IDS: string[] = [];

View File

@@ -123,10 +123,10 @@
"billingVatHelp": "Ihre registrierte MWST-Nummer. Falls Ihre Firma von der MWST befreit ist, leer lassen und in den Notizen erläutern.",
"billingNotesPlaceholderPersonal": "Was wir wissen sollten — bevorzugte Zahlungsart, Rechnungsreferenz, etc.",
"reviewContactPersonPrefix": "z.Hd.",
"autoPayRequiredError": "Auto-Zahlung muss vor der Bestellung einer neuen Instanz eingerichtet sein. Richten Sie zuerst die Auto-Zahlung ein und senden Sie das Formular erneut.",
"autoPaySetupLink": "Auto-Zahlung einrichten →",
"setupFeeNoticeHeading": "Einrichtungsgebühr wird beim Senden belastet",
"setupFeeNoticeBody": "Mit dem nächsten Klick werden Sie zu Stripe weitergeleitet, um die einmalige Einrichtungsgebühr für diese Instanz zu bezahlen. Anschliessend gelangen Sie direkt zurück zum Dashboard. Die Instanz startet erst nach Admin-Freigabe — monatliche Gebühren beginnen ab dem Freigabedatum."
"setupFeeNoticeBody": "Mit dem nächsten Klick werden Sie zu Stripe weitergeleitet, um Ihre Zahlungsdetails einzugeben und die einmalige Einrichtungsgebühr zu bezahlen. Ihre Karte wird automatisch für die zukünftige monatliche Abrechnung gespeichert. Anschliessend gelangen Sie direkt zurück zum Dashboard. Die Instanz startet erst nach Admin-Freigabe — monatliche Gebühren beginnen ab dem Freigabedatum.",
"setupFeeAmountLabel": "Einmalige Einrichtungsgebühr",
"setupFeePlusVat": "+ MwSt."
},
"dashboard": {
"title": "Dashboard",
@@ -323,7 +323,8 @@
"activationRejected": "Abgelehnt",
"tryAgain": "Erneut versuchen",
"credentialsSaved": "Zugangsdaten gespeichert",
"credentialsSavedTip": "Die eingegebenen Zugangsdaten sind sicher gespeichert und werden verwendet, sobald die Aktivierung vom Admin genehmigt wurde. Sie müssen sie nicht erneut eingeben."
"credentialsSavedTip": "Die eingegebenen Zugangsdaten sind sicher gespeichert und werden verwendet, sobald die Aktivierung vom Admin genehmigt wurde. Sie müssen sie nicht erneut eingeben.",
"recommended": "Empfohlen"
},
"admin": {
"title": "Plattform-Admin",

View File

@@ -123,10 +123,10 @@
"billingVatHelp": "Your registered VAT identifier. If your company is VAT-exempt, leave blank and explain in the notes field.",
"billingNotesPlaceholderPersonal": "Anything we should know — preferred payment method, billing reference, etc.",
"reviewContactPersonPrefix": "Attn:",
"autoPayRequiredError": "Auto-pay is required before ordering a new instance. Set up auto-pay first, then submit again.",
"autoPaySetupLink": "Set up auto-pay →",
"setupFeeNoticeHeading": "Setup fee will be charged on submit",
"setupFeeNoticeBody": "On the next click you'll be redirected to Stripe to pay the one-time setup fee for this instance. You'll be brought back to your dashboard immediately afterwards. The instance starts running only after admin approval — monthly fees begin from the approval date."
"setupFeeNoticeBody": "On the next click you'll be redirected to Stripe to enter your payment details and pay the one-time setup fee. Your card is saved automatically for future monthly billing. You'll be brought back to your dashboard immediately afterwards. The instance starts running only after admin approval — monthly fees begin from the approval date.",
"setupFeeAmountLabel": "One-time setup fee",
"setupFeePlusVat": "+ VAT"
},
"dashboard": {
"title": "Dashboard",
@@ -323,7 +323,8 @@
"activationRejected": "Rejected",
"tryAgain": "Try again",
"credentialsSaved": "credentials saved",
"credentialsSavedTip": "The credentials you entered are securely stored and will be used as soon as admin approves the activation. You don't need to re-enter them."
"credentialsSavedTip": "The credentials you entered are securely stored and will be used as soon as admin approves the activation. You don't need to re-enter them.",
"recommended": "Recommended"
},
"admin": {
"title": "Platform Admin",

View File

@@ -123,10 +123,10 @@
"billingVatHelp": "Votre identifiant TVA enregistré. Si votre entreprise est exonérée de TVA, laissez vide et précisez dans les notes.",
"billingNotesPlaceholderPersonal": "Tout ce que nous devons savoir — moyen de paiement préféré, référence de facturation, etc.",
"reviewContactPersonPrefix": "À l'attention de",
"autoPayRequiredError": "Le paiement automatique est requis avant de commander une nouvelle instance. Configurez d'abord le paiement automatique, puis soumettez à nouveau.",
"autoPaySetupLink": "Configurer le paiement automatique →",
"setupFeeNoticeHeading": "Les frais de configuration seront facturés à l'envoi",
"setupFeeNoticeBody": "Au prochain clic vous serez redirigé vers Stripe pour régler les frais d'activation uniques de cette instance. Vous reviendrez immédiatement au tableau de bord. L'instance ne démarre qu'après validation par l'administrateur — les frais mensuels commencent à compter de la date de validation."
"setupFeeNoticeBody": "Au prochain clic vous serez redirigé vers Stripe pour saisir vos coordonnées de paiement et régler les frais d'activation uniques. Votre carte est enregistrée automatiquement pour la facturation mensuelle future. Vous reviendrez immédiatement au tableau de bord. L'instance ne démarre qu'après validation par l'administrateur — les frais mensuels commencent à compter de la date de validation.",
"setupFeeAmountLabel": "Frais d'activation uniques",
"setupFeePlusVat": "+ TVA"
},
"dashboard": {
"title": "Tableau de bord",
@@ -323,7 +323,8 @@
"activationRejected": "Refusée",
"tryAgain": "Réessayer",
"credentialsSaved": "identifiants enregistrés",
"credentialsSavedTip": "Les identifiants saisis sont stockés en sécurité et seront utilisés dès l'approbation de l'activation par l'administrateur. Vous n'avez pas besoin de les ressaisir."
"credentialsSavedTip": "Les identifiants saisis sont stockés en sécurité et seront utilisés dès l'approbation de l'activation par l'administrateur. Vous n'avez pas besoin de les ressaisir.",
"recommended": "Recommandé"
},
"admin": {
"title": "Admin plateforme",

View File

@@ -123,10 +123,10 @@
"billingVatHelp": "Il tuo identificativo IVA registrato. Se la tua azienda è esente IVA, lascia vuoto e spiega nelle note.",
"billingNotesPlaceholderPersonal": "Qualsiasi cosa dovremmo sapere — metodo di pagamento preferito, riferimento per fatturazione, ecc.",
"reviewContactPersonPrefix": "c.a.",
"autoPayRequiredError": "Il pagamento automatico è obbligatorio prima di ordinare una nuova istanza. Configuri prima il pagamento automatico, poi invii nuovamente.",
"autoPaySetupLink": "Configura pagamento automatico →",
"setupFeeNoticeHeading": "Le spese di attivazione saranno addebitate all'invio",
"setupFeeNoticeBody": "Al clic successivo sarà reindirizzato a Stripe per pagare le spese di attivazione una tantum per questa istanza. Tornerà subito alla dashboard. L'istanza si avvia solo dopo l'approvazione dell'admin — i canoni mensili decorrono dalla data di approvazione."
"setupFeeNoticeBody": "Al clic successivo sarà reindirizzato a Stripe per inserire i dati di pagamento e pagare le spese di attivazione una tantum. La sua carta viene salvata automaticamente per la fatturazione mensile futura. Tornerà subito alla dashboard. L'istanza si avvia solo dopo l'approvazione dell'admin — i canoni mensili decorrono dalla data di approvazione.",
"setupFeeAmountLabel": "Spese di attivazione una tantum",
"setupFeePlusVat": "+ IVA"
},
"dashboard": {
"title": "Dashboard",
@@ -323,7 +323,8 @@
"activationRejected": "Rifiutata",
"tryAgain": "Riprova",
"credentialsSaved": "credenziali salvate",
"credentialsSavedTip": "Le credenziali inserite sono memorizzate in modo sicuro e saranno utilizzate non appena l'attivazione viene approvata dall'amministratore. Non è necessario reinserirle."
"credentialsSavedTip": "Le credenziali inserite sono memorizzate in modo sicuro e saranno utilizzate non appena l'attivazione viene approvata dall'amministratore. Non è necessario reinserirle.",
"recommended": "Consigliato"
},
"admin": {
"title": "Admin piattaforma",