Compare commits

...

1 Commits

Author SHA1 Message Date
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 208 additions and 160 deletions

View File

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

View File

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

View File

@@ -1,51 +1,27 @@
import { NextResponse } from "next/server"; 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 * Auto-pay is no longer a customer-toggleable setting. A saved
* org. The body is `{ enabled: boolean }`. * 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 * This route is kept as an explicit 410 (Gone) so any stale client
* auto-charge against the saved card. The customer pays * that still POSTs here fails loudly rather than silently toggling
* manually (or admin marks paid) — same flow as a bank-transfer * a flag the customer shouldn't control. The old behaviour lived
* customer. * here through Phase 9b-2.
*
* 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).
*/ */
export async function POST() {
const bodySchema = z.object({ return NextResponse.json(
enabled: z.boolean(), {
}); error:
"Auto-pay can no longer be disabled. A saved card is required for service. " +
export async function POST(request: Request) { "Contact support if you need to switch to bank-transfer billing.",
const user = await getSessionUser(); code: "auto_pay_not_toggleable",
if (!user) { },
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); { status: 410 }
} );
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 }
);
}
} }

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getSessionUser, canMutate } from "@/lib/session"; import { getSessionUser, canMutate } from "@/lib/session";
import { import {
getInvoiceById,
getTenantRequestById, getTenantRequestById,
updateTenantRequestStatus, updateTenantRequestStatus,
updateTenantRequestEditableFields, updateTenantRequestEditableFields,
@@ -9,6 +10,8 @@ import { encryptSecrets } from "@/lib/crypto";
import { setTenantAnnotation } from "@/lib/k8s"; import { setTenantAnnotation } from "@/lib/k8s";
import { onboardingSchema } from "@/lib/validation"; import { onboardingSchema } from "@/lib/validation";
import { safeError } from "@/lib/errors"; 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. * Customer-side controls for a single tenant_request row.
@@ -29,7 +32,7 @@ async function loadAuthorized(
id: string id: string
): Promise< ): Promise<
| { error: NextResponse } | { error: NextResponse }
| { req: Awaited<ReturnType<typeof getTenantRequestById>>; } | { req: TenantRequest; user: SessionUser }
> { > {
const user = await getSessionUser(); const user = await getSessionUser();
if (!user) { if (!user) {
@@ -55,7 +58,7 @@ async function loadAuthorized(
error: NextResponse.json({ error: "Not found" }, { status: 404 }), error: NextResponse.json({ error: "Not found" }, { status: 404 }),
}; };
} }
return { req: tr }; return { req: tr, user };
} }
/** /**
@@ -93,6 +96,50 @@ export async function DELETE(
try { try {
await updateTenantRequestStatus(id, "cancelled"); 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 // Customer cancels their own pending resume request: clear the
// operator-side annotation so the 60-day TTL resumes counting. // operator-side annotation so the 60-day TTL resumes counting.
// Best-effort — the operator handles missing annotation gracefully. // 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) { } catch (e: any) {
console.error("Failed to cancel request:", e); console.error("Failed to cancel request:", e);
return NextResponse.json( return NextResponse.json(

View File

@@ -27,7 +27,7 @@ import {
createSetupFeeCheckoutSession, createSetupFeeCheckoutSession,
ensureStripeCustomerForOrg, ensureStripeCustomerForOrg,
} from "@/lib/stripe"; } from "@/lib/stripe";
import { createTenantSetupFeeInvoice } from "@/lib/billing"; import { createTenantSetupFeeInvoice, voidInvoice } from "@/lib/billing";
import { deriveTenantName } from "@/lib/tenant-naming"; import { deriveTenantName } from "@/lib/tenant-naming";
import type { import type {
InvoiceBillingSnapshot, InvoiceBillingSnapshot,
@@ -417,21 +417,23 @@ export async function POST(request: Request) {
); );
} }
// Phase 9b: enforce auto-pay before accepting an order. If the // Phase 9b (revised): a saved card on file IS the consent to
// org has no saved card OR has explicitly disabled auto-charge, // auto-bill. There is no customer-facing "disable auto-pay"
// the order can't proceed — return 402 with a link to the // switch — ordering requires a card, full stop. The
// settings page where they can set up auto-pay. The wizard // auto_charge_enabled flag is now an admin-only pause (used
// surfaces this as a friendly redirect rather than an error. // during disputes) and does NOT block a customer from ordering:
// if admin has paused recurring charges, that's a separate
// concern handled on the invoice side, not here. So the gate is
// simply: do they have a card on file?
const cfg = await getOrgBillingConfig(user.orgId); const cfg = await getOrgBillingConfig(user.orgId);
const hasSavedCard = !!cfg.stripeDefaultPaymentMethodId; const hasSavedCard = !!cfg.stripeDefaultPaymentMethodId;
const autoChargeOn = cfg.autoChargeEnabled !== false; if (!hasSavedCard) {
if (!hasSavedCard || !autoChargeOn) {
return NextResponse.json( return NextResponse.json(
{ {
error: error:
"Auto-pay must be set up before ordering a new instance. " + "A payment card is required before ordering a new instance. " +
"Please save a card and ensure auto-pay is enabled on /settings/billing.", "Please save a card on /settings/billing, then submit again.",
code: "auto_pay_required", code: "card_required",
redirectTo: "/settings/billing", redirectTo: "/settings/billing",
}, },
{ status: 402 } { status: 402 }
@@ -611,7 +613,24 @@ export async function POST(request: Request) {
checkoutUrl = url; checkoutUrl = url;
} catch (e) { } catch (e) {
console.error("Failed to create setup-fee Checkout session:", 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); await deletePendingPaymentRequest(tenantRequest.id).catch(() => undefined);
return NextResponse.json( return NextResponse.json(
{ error: "Failed to start payment. Please try again." }, { error: "Failed to start payment. Please try again." },

View File

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

View File

@@ -108,6 +108,14 @@ interface WizardProps {
* billingAddress snapshot). * billingAddress snapshot).
*/ */
existingOrgBilling?: OrgBilling | null; 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 * Bug 6: when present, the wizard renders in "edit" mode — fields
* are pre-populated from the request, the SOUL.md auto-fetch is * are pre-populated from the request, the SOUL.md auto-fetch is
@@ -147,6 +155,7 @@ export function OnboardingWizard({
userEmail, userEmail,
hasOrgBilling, hasOrgBilling,
existingOrgBilling, existingOrgBilling,
setupFeeChf,
editingRequest, editingRequest,
onComplete, onComplete,
}: WizardProps) { }: WizardProps) {
@@ -482,14 +491,14 @@ export function OnboardingWizard({
}), }),
}); });
// Phase 9b: 402 means the org needs to set up auto-pay // Phase 9b (revised): 402 means the org needs a saved card
// before ordering. Surface a friendly message with a link to // before ordering. There's no "enable auto-pay" step anymore
// /settings/billing instead of the generic submission error. // — a card on file is all that's required.
if (res.status === 402) { if (res.status === 402) {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (data?.code === "auto_pay_required") { if (data?.code === "card_required" || data?.code === "auto_pay_required") {
setAutoPayRequired(true); setAutoPayRequired(true);
setError(t("autoPayRequiredError")); setError(t("cardRequiredError"));
return; return;
} }
throw new Error(data.error || "Submission failed"); throw new Error(data.error || "Submission failed");
@@ -755,7 +764,9 @@ export function OnboardingWizard({
className={`border rounded-lg overflow-hidden transition-colors ${ className={`border rounded-lg overflow-hidden transition-colors ${
isSelected isSelected
? "border-accent bg-accent/5" ? "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 */} {/* Toggle row */}
@@ -774,6 +785,11 @@ export function OnboardingWizard({
> >
{pkg.name} {pkg.name}
</span> </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 && ( {pkg.requiresSecrets && (
<span className="ml-1.5 text-[10px] text-text-muted"> <span className="ml-1.5 text-[10px] text-text-muted">
({tPkg("requiresApiKey")}) ({tPkg("requiresApiKey")})
@@ -1065,28 +1081,6 @@ export function OnboardingWizard({
</p> </p>
</FieldWithError> </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>
<div className="flex justify-between mt-6"> <div className="flex justify-between mt-6">
@@ -1248,32 +1242,34 @@ export function OnboardingWizard({
value={userEmail || ""} value={userEmail || ""}
mono 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> </div>
<p className="text-xs text-text-muted">{t("confirmNote")}</p> <p className="text-xs text-text-muted">{t("confirmNote")}</p>
{/* Phase 9b: order-time setup-fee notice. The exact {/* Phase 9b: order-time setup-fee notice + amount. The
amount is determined server-side at submit (the figure shown is the net platform fee (before VAT);
platform_pricing table is the authority), but the VAT is added server-side based on the billing
customer should know that *some* charge happens on country. We show "+ VAT" rather than a computed
the next click. Wording is neutral about the amount gross to avoid mis-displaying a country-dependent
— we don't want to mis-display a stale figure. */} total. If setupFeeChf is null/0, no charge happens
<div className="text-xs rounded-md border border-accent/30 bg-accent/10 text-text-secondary px-3 py-3 mt-4"> and the whole block is suppressed. */}
<strong className="block text-text-primary mb-1"> {typeof setupFeeChf === "number" && setupFeeChf > 0 && (
{t("setupFeeNoticeHeading")} <div className="text-xs rounded-md border border-accent/30 bg-accent/10 text-text-secondary px-3 py-3 mt-4">
</strong> <strong className="block text-text-primary mb-1">
{t("setupFeeNoticeBody")} {t("setupFeeNoticeHeading")}
</div> </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> </div>
{error && ( {error && (

View File

@@ -57,7 +57,7 @@ export function SavedCardSection({
const t = useTranslations("settingsBilling"); const t = useTranslations("settingsBilling");
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [busy, setBusy] = useState<null | "setup" | "remove" | "toggle">(null); const [busy, setBusy] = useState<null | "setup" | "remove">(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
// Refresh + clean the URL when Stripe redirects back. Stripe's // 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. // Empty state — no card on file.
if (!hasCard) { if (!hasCard) {
return ( return (
@@ -262,17 +243,6 @@ export function SavedCardSection({
? t("savedCardRedirecting") ? t("savedCardRedirecting")
: t("savedCardUpdateBtn")} : t("savedCardUpdateBtn")}
</button> </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 <button
onClick={removeCard} onClick={removeCard}
disabled={busy !== null} disabled={busy !== null}

View File

@@ -76,6 +76,14 @@ export interface PackageDef {
* admin does the manual work, then approves. * admin does the manual work, then approves.
*/ */
requiresManualSetup?: boolean; 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[] = [ export const PACKAGE_CATALOG: PackageDef[] = [
@@ -173,6 +181,7 @@ export const PACKAGE_CATALOG: PackageDef[] = [
instructionsKey: "packages.threema.instructions", instructionsKey: "packages.threema.instructions",
disclaimerKey: "packages.threema.disclaimer", disclaimerKey: "packages.threema.disclaimer",
category: "channel", category: "channel",
recommended: true,
}, },
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -231,7 +240,6 @@ export const PACKAGE_CATALOG: PackageDef[] = [
}, },
{ {
id: "gog", id: "gog",
requiresManualSetup: true,
name: "Google Workspace (Gog)", name: "Google Workspace (Gog)",
descriptionKey: "packages.gog.description", descriptionKey: "packages.gog.description",
requiresSecrets: true, 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 * audio spend on every inbound voice note (Whisper STT) and every
* outbound reply (kani-tts / kokoro-fastapi via LiteLLM). Opt-in keeps * outbound reply (kani-tts / kokoro-fastapi via LiteLLM). Opt-in keeps
* cost predictable for tenants who don't intend to use voice channels. * 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[] = [ export const DEFAULT_PACKAGE_IDS: string[] = [];
"core-heartbeat",
"core-cron",
"core-active-memory",
];

View File

@@ -124,9 +124,12 @@
"billingNotesPlaceholderPersonal": "Was wir wissen sollten — bevorzugte Zahlungsart, Rechnungsreferenz, etc.", "billingNotesPlaceholderPersonal": "Was wir wissen sollten — bevorzugte Zahlungsart, Rechnungsreferenz, etc.",
"reviewContactPersonPrefix": "z.Hd.", "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.", "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 →", "autoPaySetupLink": "Karte hinzufügen →",
"setupFeeNoticeHeading": "Einrichtungsgebühr wird beim Senden belastet", "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 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.",
"cardRequiredError": "Vor der Bestellung ist eine Zahlungskarte erforderlich. Fügen Sie eine Karte hinzu und senden Sie erneut.",
"setupFeeAmountLabel": "Einmalige Einrichtungsgebühr",
"setupFeePlusVat": "+ MwSt."
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -323,7 +326,8 @@
"activationRejected": "Abgelehnt", "activationRejected": "Abgelehnt",
"tryAgain": "Erneut versuchen", "tryAgain": "Erneut versuchen",
"credentialsSaved": "Zugangsdaten gespeichert", "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": { "admin": {
"title": "Plattform-Admin", "title": "Plattform-Admin",

View File

@@ -124,9 +124,12 @@
"billingNotesPlaceholderPersonal": "Anything we should know — preferred payment method, billing reference, etc.", "billingNotesPlaceholderPersonal": "Anything we should know — preferred payment method, billing reference, etc.",
"reviewContactPersonPrefix": "Attn:", "reviewContactPersonPrefix": "Attn:",
"autoPayRequiredError": "Auto-pay is required before ordering a new instance. Set up auto-pay first, then submit again.", "autoPayRequiredError": "Auto-pay is required before ordering a new instance. Set up auto-pay first, then submit again.",
"autoPaySetupLink": "Set up auto-pay →", "autoPaySetupLink": "Add a card →",
"setupFeeNoticeHeading": "Setup fee will be charged on submit", "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 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.",
"cardRequiredError": "A payment card is required before ordering. Add a card, then submit again.",
"setupFeeAmountLabel": "One-time setup fee",
"setupFeePlusVat": "+ VAT"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -323,7 +326,8 @@
"activationRejected": "Rejected", "activationRejected": "Rejected",
"tryAgain": "Try again", "tryAgain": "Try again",
"credentialsSaved": "credentials saved", "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": { "admin": {
"title": "Platform Admin", "title": "Platform Admin",

View File

@@ -124,9 +124,12 @@
"billingNotesPlaceholderPersonal": "Tout ce que nous devons savoir — moyen de paiement préféré, référence de facturation, etc.", "billingNotesPlaceholderPersonal": "Tout ce que nous devons savoir — moyen de paiement préféré, référence de facturation, etc.",
"reviewContactPersonPrefix": "À l'attention de", "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.", "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 →", "autoPaySetupLink": "Ajouter une carte →",
"setupFeeNoticeHeading": "Les frais de configuration seront facturés à l'envoi", "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 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.",
"cardRequiredError": "Une carte de paiement est requise avant de commander. Ajoutez une carte, puis soumettez à nouveau.",
"setupFeeAmountLabel": "Frais d'activation uniques",
"setupFeePlusVat": "+ TVA"
}, },
"dashboard": { "dashboard": {
"title": "Tableau de bord", "title": "Tableau de bord",
@@ -323,7 +326,8 @@
"activationRejected": "Refusée", "activationRejected": "Refusée",
"tryAgain": "Réessayer", "tryAgain": "Réessayer",
"credentialsSaved": "identifiants enregistrés", "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": { "admin": {
"title": "Admin plateforme", "title": "Admin plateforme",

View File

@@ -124,9 +124,12 @@
"billingNotesPlaceholderPersonal": "Qualsiasi cosa dovremmo sapere — metodo di pagamento preferito, riferimento per fatturazione, ecc.", "billingNotesPlaceholderPersonal": "Qualsiasi cosa dovremmo sapere — metodo di pagamento preferito, riferimento per fatturazione, ecc.",
"reviewContactPersonPrefix": "c.a.", "reviewContactPersonPrefix": "c.a.",
"autoPayRequiredError": "Il pagamento automatico è obbligatorio prima di ordinare una nuova istanza. Configuri prima il pagamento automatico, poi invii nuovamente.", "autoPayRequiredError": "Il pagamento automatico è obbligatorio prima di ordinare una nuova istanza. Configuri prima il pagamento automatico, poi invii nuovamente.",
"autoPaySetupLink": "Configura pagamento automatico →", "autoPaySetupLink": "Aggiungi una carta →",
"setupFeeNoticeHeading": "Le spese di attivazione saranno addebitate all'invio", "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 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.",
"cardRequiredError": "Prima di ordinare è necessaria una carta di pagamento. Aggiunga una carta e invii nuovamente.",
"setupFeeAmountLabel": "Spese di attivazione una tantum",
"setupFeePlusVat": "+ IVA"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -323,7 +326,8 @@
"activationRejected": "Rifiutata", "activationRejected": "Rifiutata",
"tryAgain": "Riprova", "tryAgain": "Riprova",
"credentialsSaved": "credenziali salvate", "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": { "admin": {
"title": "Admin piattaforma", "title": "Admin piattaforma",