Phase8: Auto bill credit card
All checks were successful
Build and Push / build (push) Successful in 1m45s

This commit is contained in:
2026-05-28 23:27:32 +02:00
parent 1741574eb2
commit a6ed74b1be
12 changed files with 288 additions and 69 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

@@ -255,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(() => {
@@ -474,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" },
@@ -483,6 +505,10 @@ export function OnboardingWizard({
Object.keys(secretsPayload).length > 0
? secretsPayload
: undefined,
channelUsers:
Object.keys(channelUsersPayload).length > 0
? channelUsersPayload
: undefined,
}),
});
@@ -877,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

@@ -297,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

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-stellige ID, die in Ihrer Threema-App unter Einstellungen → Meine Threema-ID angezeigt wird. Nach der Freigabe können Sie direkt von diesem Threema-Account aus mit dem Assistenten chatten. Weitere autorisierte IDs können Sie später auf der Mandantenseite hinzufügen."
}
},
"dashboard": {
"title": "Dashboard",
@@ -326,7 +342,9 @@
"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",
"threemaBotIdHeading": "Bot-Threema-ID",
"threemaBotIdHint": "Sobald Ihr Mandant freigegeben ist, scannen Sie diesen QR-Code mit Threema, um den Assistenten zu Ihren Kontakten hinzuzufügen. Der QR-Code ist für jeden PieCed-Mandanten identisch — Sie können ihn schon jetzt speichern."
"threemaBotIdHint": "Sobald Ihr Mandant freigegeben ist, scannen Sie diesen QR-Code mit Threema, um den Assistenten zu Ihren Kontakten hinzuzufügen. Der QR-Code ist für jeden PieCed-Mandanten identisch — Sie können ihn schon jetzt speichern.",
"showInfo": "Info",
"showInfoTitle": "Setup-Info erneut anzeigen"
},
"admin": {
"title": "Plattform-Admin",

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-character ID shown in your Threema app under Settings → My Threema ID. Once approved, you can chat with the assistant directly from this Threema account. You can add more authorized IDs later from the tenant page."
}
},
"dashboard": {
"title": "Dashboard",
@@ -326,7 +342,9 @@
"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",
"threemaBotIdHeading": "Bot Threema ID",
"threemaBotIdHint": "Once your tenant is approved, scan this QR with Threema to add the assistant to your contacts. The QR is the same for every PieCed tenant — you can save it now."
"threemaBotIdHint": "Once your tenant is approved, scan this QR with Threema to add the assistant to your contacts. The QR is the same for every PieCed tenant — you can save it now.",
"showInfo": "Info",
"showInfoTitle": "Show setup info again"
},
"admin": {
"title": "Platform Admin",

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": "L'identifiant à 8 caractères affiché dans votre app Threema sous Paramètres → Mon ID Threema. Une fois approuvé, vous pourrez chatter avec l'assistant directement depuis ce compte Threema. Vous pourrez ajouter d'autres ID autorisés plus tard depuis la page du tenant."
}
},
"dashboard": {
"title": "Tableau de bord",
@@ -326,7 +342,9 @@
"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é",
"threemaBotIdHeading": "ID Threema du bot",
"threemaBotIdHint": "Une fois votre tenant approuvé, scannez ce QR avec Threema pour ajouter l'assistant à vos contacts. Le QR est identique pour chaque tenant PieCed — vous pouvez l'enregistrer dès maintenant."
"threemaBotIdHint": "Une fois votre tenant approuvé, scannez ce QR avec Threema pour ajouter l'assistant à vos contacts. Le QR est identique pour chaque tenant PieCed — vous pouvez l'enregistrer dès maintenant.",
"showInfo": "Info",
"showInfoTitle": "Réafficher les infos de configuration"
},
"admin": {
"title": "Admin plateforme",

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": "L'ID di 8 caratteri mostrato nella sua app Threema in Impostazioni → Il mio ID Threema. Una volta approvato, potrà chattare con l'assistente direttamente da questo account Threema. Potrà aggiungere altri ID autorizzati in seguito dalla pagina del tenant."
}
},
"dashboard": {
"title": "Dashboard",
@@ -326,7 +342,9 @@
"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",
"threemaBotIdHeading": "ID Threema del bot",
"threemaBotIdHint": "Una volta approvato il suo tenant, scansioni questo QR con Threema per aggiungere l'assistente ai suoi contatti. Il QR è identico per ogni tenant PieCed — può salvarlo già adesso."
"threemaBotIdHint": "Una volta approvato il suo tenant, scansioni questo QR con Threema per aggiungere l'assistente ai suoi contatti. Il QR è identico per ogni tenant PieCed — può salvarlo già adesso.",
"showInfo": "Info",
"showInfoTitle": "Mostra di nuovo le info di setup"
},
"admin": {
"title": "Admin piattaforma",

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[]>;
}
// ---------------------------------------------------------------------------