Compare commits

...

3 Commits

Author SHA1 Message Date
6f8de14b4a Phase8: Auto bill credit card
All checks were successful
Build and Push / build (push) Successful in 1m48s
2026-05-28 23:45:15 +02:00
a6ed74b1be Phase8: Auto bill credit card
All checks were successful
Build and Push / build (push) Successful in 1m45s
2026-05-28 23:27:32 +02:00
1741574eb2 Phase8: Auto bill credit card
All checks were successful
Build and Push / build (push) Successful in 1m54s
2026-05-28 23:03:46 +02:00
12 changed files with 363 additions and 77 deletions

View File

@@ -4,14 +4,12 @@ import {
getTenantRequestById,
updateTenantRequestStatus,
clearEncryptedSecrets,
recordTenantCreated,
recordSkillEvents,
recordSuspensionEvent,
} from "@/lib/db";
import { createTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s";
import { sendApprovalEmail, sendResumeApprovalEmail } from "@/lib/email";
import { decryptSecrets } from "@/lib/crypto";
import { writePackageSecrets } from "@/lib/openbao";
import { createRoute as createRelayRoute } from "@/lib/threema-relay";
import {
getDefaultSoulMd,
getDefaultAgentsMd,
@@ -88,23 +86,6 @@ export async function POST(
}
try {
await patchTenantSpec(tenantRequest.tenantName, { suspend: false });
// Billing — Phase 1: record the resume so monthly proration
// counts the suspended segment correctly. Best-effort; if
// logging fails, the approval still succeeds.
try {
await recordSuspensionEvent(
tenantRequest.tenantName,
tenantRequest.zitadelOrgId,
"resumed"
);
} catch (e) {
console.error(
"billing: failed to record resumed suspension event:",
e
);
}
// Clear the annotation that pauses the operator's 60-day TTL.
// Best-effort — annotation cleanup is also done by the operator
// when it sees suspend=false on the next reconcile (it clears
@@ -197,6 +178,29 @@ export async function POST(
? tenantRequest.contactName || "Assistant"
: tenantRequest.companyName;
// Phase 9b: split the customer's initial channel-user ids into
// (a) ids the operator needs in spec.channelUsers (telegram,
// discord, …) — passed straight into createTenant
// (b) Threema ids that ALSO need a relay route registered so
// inbound messages reach this tenant. Threema is in (a)
// AND (b): spec.channelUsers tells the operator the id is
// authorized; the relay's route maps inbound traffic from
// that id to this tenant.
const initialChannelUsers = tenantRequest.channelUsers ?? {};
// Strip channels the customer didn't actually enable (defensive
// — the wizard already filters this, but the row could carry
// stale data if the customer edited their request post-submit).
const filteredChannelUsers: Record<string, string[]> = {};
for (const [channel, ids] of Object.entries(initialChannelUsers)) {
if (!packages.includes(channel)) continue;
const cleaned = (ids ?? [])
.map((s) => (s ?? "").trim())
.filter((s) => s.length > 0);
if (cleaned.length > 0) {
filteredChannelUsers[channel] = cleaned;
}
}
await createTenant(
tenantName,
{
@@ -204,6 +208,9 @@ export async function POST(
agentName: tenantRequest.agentName,
packages,
workspaceFiles,
...(Object.keys(filteredChannelUsers).length > 0
? { channelUsers: filteredChannelUsers }
: {}),
},
{
"pieced.ch/zitadel-org-id": tenantRequest.zitadelOrgId,
@@ -219,33 +226,33 @@ export async function POST(
}
);
// Billing — Phase 1: record the tenant's creation and initial
// package state. Anchored at "now" rather than the CR's
// creationTimestamp because we don't get the timestamp back from
// createTenant — the few-millisecond skew vs the CR's actual
// creationTimestamp is irrelevant for monthly billing.
//
// Best-effort: tracking failures must never block provisioning.
// The backfill helper can repair any gaps later if needed.
const billingAnchor = new Date();
try {
await recordTenantCreated(
tenantName,
tenantRequest.zitadelOrgId,
billingAnchor
);
await recordSkillEvents(
tenantName,
tenantRequest.zitadelOrgId,
packages,
[],
billingAnchor
);
} catch (e) {
console.error(
"billing: failed to record tenant creation / initial skill events:",
e
);
// Threema: register relay routes for each id the customer
// entered. Best-effort — a route failure doesn't unwind the
// tenant creation (admin can retry from the tenant page later).
// The Threema package itself isn't enabled on the tenant until
// the customer toggles it from the tenant detail page (which
// also mints the per-tenant token); the routes here pre-warm
// the relay so the first toggle works without re-typing the id.
if (
packages.includes("threema") &&
filteredChannelUsers.threema &&
filteredChannelUsers.threema.length > 0
) {
for (const tid of filteredChannelUsers.threema) {
try {
const res = await createRelayRoute(tenantName, tid);
if (!res.ok) {
console.warn(
`[approve] Threema route create for tenant=${tenantName} id=${tid} returned not-ok: ${res.message}`
);
}
} catch (e) {
console.error(
`[approve] Threema route create threw for tenant=${tenantName} id=${tid}:`,
e
);
}
}
}
// Step 5: Update request status — clear admin notes on re-approval

View File

@@ -208,6 +208,7 @@ export async function POST(request: Request) {
const input: OnboardingInput & {
packageSecrets?: Record<string, Record<string, string>>;
channelUsers?: Record<string, string[]>;
} = parsed.data;
// Look up an existing approved request for this org to inherit
@@ -443,6 +444,7 @@ export async function POST(request: Request) {
billingNotes,
encryptedSecrets,
isPersonal,
channelUsers: input.channelUsers ?? {},
});
try {
await sendAdminNotificationEmail(
@@ -487,6 +489,7 @@ export async function POST(request: Request) {
billingNotes,
encryptedSecrets,
isPersonal,
channelUsers: input.channelUsers ?? {},
});
// Derive the future tenant_name — needed on the invoice line so

View File

@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { Card } from "@/components/ui/card";
import { PACKAGE_CATALOG, DEFAULT_PACKAGE_IDS, type PackageDef } from "@/lib/packages";
import { isPersonalOrgName, displayOrgNameFor } from "@/lib/personal-org";
import { THREEMA_GATEWAY } from "@/lib/threema-gateway-config";
import {
configureStepSchema,
billingStepSchema,
@@ -254,6 +255,14 @@ export function OnboardingWizard({
const [disclaimerAccepted, setDisclaimerAccepted] = useState<
Record<string, boolean>
>({});
// Phase 9b: per-channel customer user id collected at onboarding.
// Keyed by package id (e.g. "telegram" → "1234567"). Applied on
// admin approval — see /api/admin/requests/[id]/approve. Optional
// per channel; the customer can also leave it blank and add their
// id later from the tenant's channel-users page.
const [channelUserIds, setChannelUserIds] = useState<Record<string, string>>(
{}
);
// Fetch DB-stored defaults on mount
useEffect(() => {
@@ -473,6 +482,20 @@ export function OnboardingWizard({
})()
: config;
// Phase 9b: build the channelUsers payload from the per-package
// ids collected during onboarding. Only include channels that
// (a) are enabled in the wizard's packages list AND
// (b) have a non-empty id entered.
// Shape matches PiecedTenantSpec.channelUsers — { channel: [id] }
// — so the approve handler can pass it straight through.
const channelUsersPayload: Record<string, string[]> = {};
for (const [pkgId, rawId] of Object.entries(channelUserIds)) {
const trimmed = (rawId ?? "").trim();
if (!trimmed) continue;
if (!config.packages.includes(pkgId)) continue;
channelUsersPayload[pkgId] = [trimmed];
}
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
@@ -482,6 +505,10 @@ export function OnboardingWizard({
Object.keys(secretsPayload).length > 0
? secretsPayload
: undefined,
channelUsers:
Object.keys(channelUsersPayload).length > 0
? channelUsersPayload
: undefined,
}),
});
@@ -814,6 +841,40 @@ export function OnboardingWizard({
</div>
)}
{/* Threema: show the bot's Threema ID
and QR right here in the wizard. The
instructions text refers to a QR
that isn't visible until after
provisioning — without this block
the message is confusing. The QR is
the platform's shared gateway QR
(*AIAGENT), identical for every
tenant, so we can render it before
the tenant even exists. */}
{pkg.id === "threema" && (
<div className="rounded-lg border border-accent/30 bg-surface-1 p-3 flex items-start gap-3">
<div className="bg-white p-1.5 rounded-md shrink-0">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={THREEMA_GATEWAY.qrCodePath}
alt={`QR code for ${THREEMA_GATEWAY.displayName}`}
width={96}
height={96}
style={{ display: "block" }}
/>
</div>
<div className="text-xs text-text-secondary leading-relaxed">
<div className="text-text-primary font-medium mb-1">
{tPkg("threemaBotIdHeading")}
</div>
<div className="font-mono text-sm text-accent mb-2">
{THREEMA_GATEWAY.displayName}
</div>
<div>{tPkg("threemaBotIdHint")}</div>
</div>
</div>
)}
{(pkg.secrets || []).map((field) => (
<label key={field.key} className="block">
<span className="text-xs text-text-secondary mb-1 block">
@@ -842,6 +903,46 @@ export function OnboardingWizard({
</label>
))}
{/* Phase 9b: channel-user-id capture
during onboarding. For channels
where the customer's own user id
is needed for routing (Telegram,
Discord, Threema), collect it here
so the assistant is usable
immediately on provisioning. The
help text comes from the existing
channelUsers.<id>IdHelp keys
(same copy as the post-provisioning
page uses). Field is optional —
blank means "I'll add it later". */}
{pkg.collectsChannelUserId && (
<label className="block">
<span className="text-xs text-text-secondary mb-1 block">
{t(`yourChannelIdLabel.${pkg.id}`)}{" "}
<span className="text-text-muted normal-case">
({t("optional")})
</span>
</span>
<input
type="text"
placeholder={t(
`yourChannelIdPlaceholder.${pkg.id}`
)}
value={channelUserIds[pkg.id] ?? ""}
onChange={(e) =>
setChannelUserIds((prev) => ({
...prev,
[pkg.id]: e.target.value,
}))
}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted font-mono focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/>
<p className="text-[11px] text-text-muted mt-1 leading-relaxed whitespace-pre-line">
{t(`yourChannelIdHelp.${pkg.id}`)}
</p>
</label>
)}
{pkg.disclaimerKey && (
<label className="flex items-start gap-2 text-xs text-text-secondary">
<input

View File

@@ -9,6 +9,7 @@ import type {
SkillPricing,
} from "@/types";
import { SkillCostDialog } from "./skill-cost-dialog";
import { ThreemaQrModal } from "@/components/channel-users/threema-qr-modal";
interface Props {
pkg: PackageDef;
@@ -51,6 +52,11 @@ export function PackageCard({
const [error, setError] = useState<string | null>(null);
// Phase 2.5: cost-disclosure flow + activation-request flow.
const [showCostDialog, setShowCostDialog] = useState(false);
// Threema: after a successful enable on customProvisioning, surface
// the gateway QR + bot Threema ID so the customer immediately knows
// how to add the assistant to their Threema contacts. Without this,
// the toggle just flips silently with no actionable info.
const [showThreemaInfo, setShowThreemaInfo] = useState(false);
const isPriced =
(pricing?.dailyPriceChf ?? 0) > 0 || (pricing?.setupFeeChf ?? 0) > 0;
@@ -79,6 +85,14 @@ export function PackageCard({
throw new Error(err.error || `Provisioning failed (HTTP ${provRes.status})`);
}
await togglePackage(true);
// For Threema specifically: now that the relay's minted the
// per-tenant token and the package is enabled, show the
// gateway QR + bot Threema ID so the customer can add the
// assistant to their Threema contacts straight away. Other
// customProvisioning packages don't need this confirmation.
if (pkg.id === "threema") {
setShowThreemaInfo(true);
}
} catch (e: any) {
setError(e.message);
} finally {
@@ -283,17 +297,33 @@ export function PackageCard({
</button>
</div>
) : canEdit ? (
<button
onClick={enabled ? handleDisable : handleEnable}
disabled={saving}
className={`ml-auto rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
enabled
? "bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2"
: "bg-accent text-surface-0 hover:bg-accent-dim shadow-lg shadow-accent/20"
} disabled:opacity-50`}
>
{saving ? "…" : enabled ? t("packages.disable") : t("packages.enable")}
</button>
<div className="ml-auto flex items-center gap-2">
{/* Phase 9b: re-open the Threema info popup at any time
while Threema is enabled. The popup auto-opens after
a fresh enable; this button lets the customer see the
QR + bot ID again without having to disable + re-enable. */}
{pkg.id === "threema" && enabled && (
<button
onClick={() => setShowThreemaInfo(true)}
className="rounded-lg px-2 py-1.5 text-xs font-medium bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2 transition-colors cursor-pointer"
title={t("packages.showInfoTitle")}
aria-label={t("packages.showInfoTitle")}
>
{t("packages.showInfo")}
</button>
)}
<button
onClick={enabled ? handleDisable : handleEnable}
disabled={saving}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
enabled
? "bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2"
: "bg-accent text-surface-0 hover:bg-accent-dim shadow-lg shadow-accent/20"
} disabled:opacity-50`}
>
{saving ? "…" : enabled ? t("packages.disable") : t("packages.enable")}
</button>
</div>
) : (
// Slice 5: read-only viewers see a static badge instead of a
// toggle. The status badge above the divider already conveys
@@ -320,6 +350,16 @@ export function PackageCard({
busy={saving}
/>
{/* Threema: post-enable confirmation showing the gateway QR
and bot Threema ID. Only rendered for the threema package
and only after a successful enable. The same modal is also
reachable later on the channel-users page. */}
{pkg.id === "threema" && (
<ThreemaQrModal
open={showThreemaInfo}
onClose={() => setShowThreemaInfo(false)}
/>
)}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="w-full max-w-md bg-surface-1 border border-border rounded-2xl p-6 space-y-4 shadow-2xl shadow-black/40">

View File

@@ -105,6 +105,14 @@ const MIGRATION_SQL = `
ON tenant_requests(setup_invoice_id)
WHERE setup_invoice_id IS NOT NULL;
-- Phase 9b: optional initial channel-user ids per channel package
-- collected during onboarding. JSONB so the shape can vary by
-- channel (today it's a string[] per channel id, matching
-- PiecedTenantSpec.channelUsers). Default '{}' so reads on legacy
-- rows return an empty object rather than null.
ALTER TABLE tenant_requests
ADD COLUMN IF NOT EXISTS channel_users JSONB NOT NULL DEFAULT '{}'::jsonb;
-- Feature 6: free-form customer note attached to the request.
-- Currently surfaced only by resume requests (where the customer
-- explains why they want reactivation), but the column is generic
@@ -896,8 +904,8 @@ export async function createTenantRequest(
(zitadel_org_id, zitadel_user_id, company_name, instance_name,
contact_name, contact_email, agent_name, soul_md, agents_md,
packages, billing_address, billing_notes, encrypted_secrets,
is_personal)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
is_personal, channel_users)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15::jsonb)
RETURNING *`,
[
params.zitadelOrgId,
@@ -914,6 +922,7 @@ export async function createTenantRequest(
params.billingNotes,
params.encryptedSecrets ?? null,
params.isPersonal ?? false,
JSON.stringify(params.channelUsers ?? {}),
]
);
return mapRow(result.rows[0]);
@@ -1449,6 +1458,7 @@ function mapRow(row: any): TenantRequest {
adminNotes: row.admin_notes,
tenantName: row.tenant_name,
setupInvoiceId: row.setup_invoice_id ?? null,
channelUsers: (row.channel_users ?? {}) as Record<string, string[]>,
encryptedSecrets: row.encrypted_secrets ?? null,
isPersonal: row.is_personal ?? false,
dismissedAt:
@@ -4235,6 +4245,7 @@ export async function createTenantRequestPendingPayment(params: {
billingNotes?: string;
encryptedSecrets?: Buffer | null;
isPersonal: boolean;
channelUsers?: Record<string, string[]>;
}): Promise<TenantRequest> {
await ensureSchema();
const result = await getPool().query(
@@ -4244,10 +4255,11 @@ export async function createTenantRequestPendingPayment(params: {
agent_name, soul_md, agents_md, packages,
billing_address, billing_notes,
encrypted_secrets, is_personal,
channel_users,
status, request_type
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12,
$13, $14, 'pending_payment', 'provision'
$13, $14, $15::jsonb, 'pending_payment', 'provision'
)
RETURNING *`,
[
@@ -4265,6 +4277,7 @@ export async function createTenantRequestPendingPayment(params: {
params.billingNotes ?? null,
params.encryptedSecrets ?? null,
params.isPersonal,
JSON.stringify(params.channelUsers ?? {}),
]
);
return mapRow(result.rows[0]);

View File

@@ -84,6 +84,21 @@ export interface PackageDef {
* stays opt-in.
*/
recommended?: boolean;
/**
* Phase 9b: when true, the onboarding wizard collects the
* customer's own user id for this channel (e.g. their Telegram
* numeric id, their Threema ID) at request time. The collected
* id is forwarded with the tenant request, stored on the row,
* and applied on admin approval:
* - spec.channelUsers[<channel>] gets the id seeded so the
* operator's first reconcile already has it
* - for Threema specifically, the approve handler additionally
* calls the relay's createRoute() so inbound messages from
* that id reach the new tenant
* Customers can add more ids later via the channel-users page.
* Help copy and label come from channelUsers.<id>IdHelp.
*/
collectsChannelUserId?: boolean;
}
export const PACKAGE_CATALOG: PackageDef[] = [
@@ -137,6 +152,7 @@ export const PACKAGE_CATALOG: PackageDef[] = [
instructionsKey: "packages.telegram.instructions",
disclaimerKey: "packages.telegram.disclaimer",
category: "channel",
collectsChannelUserId: true,
},
{
id: "discord",
@@ -166,6 +182,7 @@ export const PACKAGE_CATALOG: PackageDef[] = [
instructionsKey: "packages.discord.instructions",
disclaimerKey: "packages.discord.disclaimer",
category: "channel",
collectsChannelUserId: true,
},
{
id: "threema",
@@ -182,6 +199,7 @@ export const PACKAGE_CATALOG: PackageDef[] = [
disclaimerKey: "packages.threema.disclaimer",
category: "channel",
recommended: true,
collectsChannelUserId: true,
},
// -------------------------------------------------------------------------

View File

@@ -152,6 +152,12 @@ export const onboardingSchema = z.object({
packageSecrets: z
.record(z.string(), z.record(z.string(), z.string()))
.optional(),
// Phase 9b: per-channel initial user ids collected during
// onboarding. Map of channel package id → list of user ids the
// customer wants to authorize. Applied at admin approval time.
channelUsers: z
.record(z.string(), z.array(z.string().trim().min(1).max(200)))
.optional(),
billingAddress: billingAddressSchema.optional(),
billingNotes: z.string().max(2_000).optional(),
});

View File

@@ -126,7 +126,23 @@
"setupFeeNoticeHeading": "Einrichtungsgebühr wird beim Senden belastet",
"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."
"setupFeePlusVat": "+ MwSt.",
"optional": "optional",
"yourChannelIdLabel": {
"telegram": "Ihre Telegram-Benutzer-ID",
"discord": "Ihre Discord-Benutzer-ID",
"threema": "Ihre Threema-ID"
},
"yourChannelIdPlaceholder": {
"telegram": "z.B. 1234567890",
"discord": "z.B. 234567890123456789",
"threema": "z.B. ABCD1234"
},
"yourChannelIdHelp": {
"telegram": "Öffnen Sie Telegram, schreiben Sie an @userinfobot und fügen Sie die zurückgegebene numerische ID hier ein. Weitere Benutzer können Sie später auf der Mandantenseite hinzufügen.",
"discord": "Aktivieren Sie den Entwicklermodus in Discord (Erweiterte Einstellungen), Rechtsklick auf Ihren Namen → Benutzer-ID kopieren, und hier einfügen. Weitere Benutzer können Sie später auf der Mandantenseite hinzufügen.",
"threema": "Die 8 Zeichen, die in Ihrer Threema-App unter Einstellungen → Meine Threema-ID angezeigt werden. Sobald Ihr Mandant freigegeben ist und Threema aktiviert wurde, können Sie aus diesem Account heraus mit dem Assistenten chatten. Weitere autorisierte IDs können später auf der Mandantenseite hinzugefügt werden."
}
},
"dashboard": {
"title": "Dashboard",
@@ -315,7 +331,7 @@
},
"threema": {
"description": "Senden und empfangen Sie Nachrichten über Threema. Jede eingehende und ausgehende Nachricht läuft über den gemeinsamen PieCed-Messaging-Dienst und verursacht eine Gebühr pro Nachricht bei Threema — eine Drittanbieter-Kostenposition, unabhängig von Ihrem PieCed-Abonnement.",
"instructions": "1. Aktivieren Sie dieses Paket.\n2. Öffnen Sie Threema auf Ihrem Telefon, scannen Sie den QR-Code unter Autorisierte Benutzer → threema und akzeptieren Sie den Kontakt.\n3. Tragen Sie Ihre eigene Threema-ID unter Autorisierte Benutzer → threema ein, damit der Assistent Ihre Nachrichten erkennt.\n4. Schreiben Sie eine Nachricht aus Threema, um das Gespräch zu beginnen.",
"instructions": "1. Öffnen Sie Threema auf Ihrem Telefon und scannen Sie den unten angezeigten QR-Code — am besten gleich jetzt, damit Sie loslegen können, sobald Ihr Mandant läuft.\n2. Tragen Sie Ihre eigene Threema-ID im Feld weiter unten ein (die 8 Zeichen aus Einstellungen → Meine Threema-ID in der Threema-App), damit der Assistent Ihre Nachrichten annimmt.\n3. Sobald Ihr Mandant freigegeben ist und läuft, senden Sie eine Nachricht aus Threema, um das Gespräch zu beginnen.",
"disclaimer": "Nachrichten zwischen Threema und PieCed werden Ende-zu-Ende verschlüsselt bis zum PieCed-Messaging-Dienst, wo sie entschlüsselt und an Ihren Assistenten weitergeleitet werden. Jede gesendete oder empfangene Nachricht wird gemäss Threema-Tarif pro Nachricht abgerechnet — die aktuellen Preise finden Sie in Ihrem Plan."
},
"manualReviewPending": "Manuelle Prüfung ausstehend",
@@ -324,7 +340,11 @@
"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.",
"recommended": "Empfohlen"
"recommended": "Empfohlen",
"threemaBotIdHeading": "Bot-Threema-ID",
"threemaBotIdHint": "Das ist die Threema-ID des Assistenten — bei jedem PieCed-Mandanten identisch. Scannen Sie den QR jetzt mit Ihrer Threema-App, damit Sie startklar sind, sobald Ihr Mandant freigegeben und Threema aktiviert ist.",
"showInfo": "Info",
"showInfoTitle": "Setup-Info erneut anzeigen"
},
"admin": {
"title": "Plattform-Admin",
@@ -417,7 +437,7 @@
"title": "Assistenten zu Threema hinzufügen",
"step1": "Öffnen Sie Threema auf Ihrem Telefon.",
"step2": "Tippen Sie auf das Scan-Symbol und scannen Sie diesen QR-Code, um den Assistenten als Kontakt hinzuzufügen.",
"step3": "Fügen Sie anschliessend unten Ihre eigene Threema-ID hinzu.",
"step3": "Stellen Sie sicher, dass Ihre Threema-ID als autorisierter Benutzer eingetragen ist, damit der Assistent Ihre Nachrichten annimmt.",
"qrAlt": "QR-Code, um {gateway} als Threema-Kontakt hinzuzufügen",
"bannerTitle": "Threema einrichten",
"bannerBody": "Öffnen Sie Threema auf Ihrem Telefon und scannen Sie unseren QR-Code, um den Assistenten als Kontakt hinzuzufügen. Geben Sie anschliessend unten Ihre eigene Threema-ID ein.",

View File

@@ -126,7 +126,23 @@
"setupFeeNoticeHeading": "Setup fee will be charged on submit",
"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"
"setupFeePlusVat": "+ VAT",
"optional": "optional",
"yourChannelIdLabel": {
"telegram": "Your Telegram user ID",
"discord": "Your Discord user ID",
"threema": "Your Threema ID"
},
"yourChannelIdPlaceholder": {
"telegram": "e.g. 1234567890",
"discord": "e.g. 234567890123456789",
"threema": "e.g. ABCD1234"
},
"yourChannelIdHelp": {
"telegram": "Open Telegram, message @userinfobot, and paste the numeric id it returns. You can add more users later from the tenant page.",
"discord": "Enable Developer Mode in Discord (Advanced settings), right-click your name → Copy User ID, and paste it here. You can add more users later from the tenant page.",
"threema": "The 8 characters shown in your Threema app under Settings → My Threema ID. Once your tenant is approved and Threema is enabled, you'll be able to chat with the assistant from this account. More authorized IDs can be added later from the tenant page."
}
},
"dashboard": {
"title": "Dashboard",
@@ -315,7 +331,7 @@
},
"threema": {
"description": "Send and receive messages through Threema. Each inbound and outbound message uses the shared PieCed messaging service and incurs a per-message charge from Threema — a third-party cost, separate from your PieCed subscription.",
"instructions": "1. Enable this package.\n2. Open Threema on your phone, scan the QR code shown under Authorized Users → threema, and accept the contact.\n3. Add your own Threema ID under Authorized Users → threema so the assistant recognises your messages.\n4. Send a message from Threema to start chatting with the assistant.",
"instructions": "1. Open Threema on your phone and scan the QR code shown below — do it now so you're ready to chat the moment your tenant is running.\n2. Enter your own Threema ID in the field below (the 8 characters from Settings → My Threema ID in your Threema app) so the assistant accepts your messages.\n3. When your tenant is approved and running, send a message from Threema to start chatting.",
"disclaimer": "Messages between Threema and PieCed are end-to-end encrypted up to PieCed's messaging service, where they are decrypted to be routed to your assistant. Each message sent or received is counted toward Threema's per-message billing — see your plan for current rates."
},
"manualReviewPending": "Manual review pending",
@@ -324,7 +340,11 @@
"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.",
"recommended": "Recommended"
"recommended": "Recommended",
"threemaBotIdHeading": "Bot Threema ID",
"threemaBotIdHint": "This is the assistant's Threema ID — identical for every PieCed tenant. Scan the QR now with your Threema app so you're ready the moment your tenant is approved and Threema is enabled.",
"showInfo": "Info",
"showInfoTitle": "Show setup info again"
},
"admin": {
"title": "Platform Admin",
@@ -417,7 +437,7 @@
"title": "Add the assistant to your Threema",
"step1": "Open Threema on your phone.",
"step2": "Tap the scan icon and scan this QR code to add the assistant as a contact.",
"step3": "Then add your own Threema ID below.",
"step3": "Make sure your Threema ID is registered as an authorized user so the assistant accepts your messages.",
"qrAlt": "QR code to add {gateway} as a Threema contact",
"bannerTitle": "Set up Threema",
"bannerBody": "Open Threema on your phone and scan our QR code to add the assistant as a contact. Then add your own Threema ID below.",

View File

@@ -126,7 +126,23 @@
"setupFeeNoticeHeading": "Les frais de configuration seront facturés à l'envoi",
"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"
"setupFeePlusVat": "+ TVA",
"optional": "facultatif",
"yourChannelIdLabel": {
"telegram": "Votre ID utilisateur Telegram",
"discord": "Votre ID utilisateur Discord",
"threema": "Votre ID Threema"
},
"yourChannelIdPlaceholder": {
"telegram": "ex. 1234567890",
"discord": "ex. 234567890123456789",
"threema": "ex. ABCD1234"
},
"yourChannelIdHelp": {
"telegram": "Ouvrez Telegram, écrivez à @userinfobot et collez l'ID numérique qu'il retourne. Vous pourrez ajouter d'autres utilisateurs plus tard depuis la page du tenant.",
"discord": "Activez le mode développeur dans Discord (paramètres avancés), clic-droit sur votre nom → Copier l'ID utilisateur, puis collez-le ici. Vous pourrez ajouter d'autres utilisateurs plus tard depuis la page du tenant.",
"threema": "Les 8 caractères affichés dans votre app Threema sous Réglages → Mon identifiant Threema. Une fois votre tenant approuvé et Threema activé, vous pourrez discuter avec l'assistant depuis ce compte. D'autres ID autorisés peuvent être ajoutés plus tard depuis la page du tenant."
}
},
"dashboard": {
"title": "Tableau de bord",
@@ -315,7 +331,7 @@
},
"threema": {
"description": "Envoyez et recevez des messages via Threema. Chaque message entrant ou sortant transite par le service de messagerie PieCed partagé et entraîne des frais par message facturés par Threema — un coût tiers, distinct de votre abonnement PieCed.",
"instructions": "1. Activez ce package.\n2. Ouvrez Threema sur votre téléphone, scannez le QR code affiché dans Utilisateurs autorisés → threema, puis acceptez le contact.\n3. Ajoutez votre propre identifiant Threema sous Utilisateurs autorisés → threema afin que l'assistant reconnaisse vos messages.\n4. Envoyez un message depuis Threema pour commencer la conversation.",
"instructions": "1. Ouvrez Threema sur votre téléphone et scannez le QR code affiché ci-dessous — faites-le dès maintenant pour être prêt à discuter dès que votre tenant sera opérationnel.\n2. Saisissez votre propre identifiant Threema dans le champ ci-dessous (les 8 caractères figurant dans Réglages → Mon identifiant Threema dans l'app Threema) afin que l'assistant accepte vos messages.\n3. Une fois votre tenant approuvé et opérationnel, envoyez un message depuis Threema pour démarrer la conversation.",
"disclaimer": "Les messages entre Threema et PieCed sont chiffrés de bout en bout jusqu'au service de messagerie PieCed, où ils sont déchiffrés pour être acheminés vers votre assistant. Chaque message envoyé ou reçu est facturé par Threema selon son tarif par message — consultez votre plan pour les tarifs en vigueur."
},
"manualReviewPending": "Revue manuelle en attente",
@@ -324,7 +340,11 @@
"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.",
"recommended": "Recommandé"
"recommended": "Recommandé",
"threemaBotIdHeading": "ID Threema du bot",
"threemaBotIdHint": "Voici l'identifiant Threema de l'assistant — identique pour chaque tenant PieCed. Scannez le QR dès maintenant avec votre app Threema afin d'être prêt dès l'approbation de votre tenant et l'activation de Threema.",
"showInfo": "Info",
"showInfoTitle": "Réafficher les infos de configuration"
},
"admin": {
"title": "Admin plateforme",
@@ -417,7 +437,7 @@
"title": "Ajouter l'assistant à Threema",
"step1": "Ouvrez Threema sur votre téléphone.",
"step2": "Appuyez sur l'icône de scan et scannez ce QR code pour ajouter l'assistant comme contact.",
"step3": "Puis ajoutez votre propre identifiant Threema ci-dessous.",
"step3": "Assurez-vous que votre identifiant Threema est enregistré comme utilisateur autorisé pour que l'assistant accepte vos messages.",
"qrAlt": "QR code pour ajouter {gateway} comme contact Threema",
"bannerTitle": "Configurer Threema",
"bannerBody": "Ouvrez Threema sur votre téléphone et scannez notre QR code pour ajouter l'assistant comme contact. Saisissez ensuite votre propre identifiant Threema ci-dessous.",

View File

@@ -126,7 +126,23 @@
"setupFeeNoticeHeading": "Le spese di attivazione saranno addebitate all'invio",
"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"
"setupFeePlusVat": "+ IVA",
"optional": "facoltativo",
"yourChannelIdLabel": {
"telegram": "Il suo ID utente Telegram",
"discord": "Il suo ID utente Discord",
"threema": "Il suo ID Threema"
},
"yourChannelIdPlaceholder": {
"telegram": "es. 1234567890",
"discord": "es. 234567890123456789",
"threema": "es. ABCD1234"
},
"yourChannelIdHelp": {
"telegram": "Apra Telegram, scriva a @userinfobot e incolli qui l'ID numerico restituito. Potrà aggiungere altri utenti in seguito dalla pagina del tenant.",
"discord": "Attivi la Modalità sviluppatore in Discord (Impostazioni avanzate), clic destro sul suo nome → Copia ID utente, poi incolli qui. Potrà aggiungere altri utenti in seguito dalla pagina del tenant.",
"threema": "Gli 8 caratteri mostrati nella sua app Threema in Impostazioni → Il mio ID Threema. Una volta approvato il suo tenant e attivato Threema, potrà chattare con l'assistente da questo account. Altri ID autorizzati possono essere aggiunti in seguito dalla pagina del tenant."
}
},
"dashboard": {
"title": "Dashboard",
@@ -315,7 +331,7 @@
},
"threema": {
"description": "Invia e ricevi messaggi tramite Threema. Ogni messaggio in entrata e in uscita passa attraverso il servizio di messaggistica condiviso di PieCed e comporta un addebito per messaggio da parte di Threema — un costo di terzi, separato dall'abbonamento PieCed.",
"instructions": "1. Attiva questo pacchetto.\n2. Apri Threema sul tuo telefono, scansiona il QR code mostrato in Utenti autorizzati → threema e accetta il contatto.\n3. Aggiungi il tuo ID Threema sotto Utenti autorizzati → threema affinché l'assistente riconosca i tuoi messaggi.\n4. Invia un messaggio da Threema per iniziare la conversazione.",
"instructions": "1. Apri Threema sul tuo telefono e scansiona il QR code mostrato qui sotto — fallo subito, così sarai pronto a chattare appena il tuo tenant sarà operativo.\n2. Inserisci il tuo ID Threema nel campo qui sotto (gli 8 caratteri da Impostazioni → Il mio ID Threema nell'app Threema) affinché l'assistente accetti i tuoi messaggi.\n3. Una volta che il tuo tenant è approvato e operativo, invia un messaggio da Threema per iniziare la conversazione.",
"disclaimer": "I messaggi tra Threema e PieCed sono cifrati end-to-end fino al servizio di messaggistica PieCed, dove vengono decifrati per essere inoltrati al tuo assistente. Ogni messaggio inviato o ricevuto viene addebitato da Threema secondo la sua tariffa per messaggio — consulta il tuo piano per i prezzi attuali."
},
"manualReviewPending": "Revisione manuale in attesa",
@@ -324,7 +340,11 @@
"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.",
"recommended": "Consigliato"
"recommended": "Consigliato",
"threemaBotIdHeading": "ID Threema del bot",
"threemaBotIdHint": "Questo è l'ID Threema dell'assistente — identico per ogni tenant PieCed. Scansioni il QR ora con la sua app Threema, così sarà pronto non appena il suo tenant verrà approvato e Threema attivato.",
"showInfo": "Info",
"showInfoTitle": "Mostra di nuovo le info di setup"
},
"admin": {
"title": "Admin piattaforma",
@@ -417,7 +437,7 @@
"title": "Aggiungi l'assistente a Threema",
"step1": "Apri Threema sul tuo telefono.",
"step2": "Tocca l'icona di scansione e scansiona questo QR code per aggiungere l'assistente ai contatti.",
"step3": "Quindi aggiungi il tuo ID Threema qui sotto.",
"step3": "Assicurati che il tuo ID Threema sia registrato come utente autorizzato così l'assistente accetterà i tuoi messaggi.",
"qrAlt": "QR code per aggiungere {gateway} come contatto Threema",
"bannerTitle": "Configura Threema",
"bannerBody": "Apri Threema sul tuo telefono e scansiona il nostro QR code per aggiungere l'assistente ai contatti. Inserisci poi il tuo ID Threema qui sotto.",

View File

@@ -298,6 +298,16 @@ export interface TenantRequest {
* rejection refunds this invoice via the existing refund flow.
*/
setupInvoiceId?: string | null;
/**
* Phase 9b: optional initial channel-user ids the customer entered
* during onboarding for each enabled channel package (e.g.
* { telegram: ["1234567"], threema: ["ABCD1234"] }). Empty/absent
* on requests that pre-date the field. Applied on admin approval:
* the values get seeded into PiecedTenantSpec.channelUsers, and
* for Threema specifically, the relay's route table is updated so
* inbound messages from those ids reach the newly-created tenant.
*/
channelUsers?: Record<string, string[]>;
encryptedSecrets?: Buffer | null;
/**
* Slice 4: true for personal accounts. Drives CR-naming (`p-{suffix}`
@@ -361,6 +371,14 @@ export interface OnboardingInput {
*/
billingAddress?: BillingAddress;
billingNotes?: string;
/**
* Phase 9b: initial channel-user ids the customer entered during
* onboarding, keyed by channel package id (e.g. { telegram:
* ["1234567"], threema: ["ABCD1234"] }). Optional — customers
* can also leave channels blank and add ids later from the
* tenant's channel-users page.
*/
channelUsers?: Record<string, string[]>;
}
// ---------------------------------------------------------------------------