Phase8: Auto bill credit card
All checks were successful
Build and Push / build (push) Successful in 1m45s
All checks were successful
Build and Push / build (push) Successful in 1m45s
This commit is contained in:
@@ -4,14 +4,12 @@ import {
|
|||||||
getTenantRequestById,
|
getTenantRequestById,
|
||||||
updateTenantRequestStatus,
|
updateTenantRequestStatus,
|
||||||
clearEncryptedSecrets,
|
clearEncryptedSecrets,
|
||||||
recordTenantCreated,
|
|
||||||
recordSkillEvents,
|
|
||||||
recordSuspensionEvent,
|
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { createTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s";
|
import { createTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s";
|
||||||
import { sendApprovalEmail, sendResumeApprovalEmail } from "@/lib/email";
|
import { sendApprovalEmail, sendResumeApprovalEmail } from "@/lib/email";
|
||||||
import { decryptSecrets } from "@/lib/crypto";
|
import { decryptSecrets } from "@/lib/crypto";
|
||||||
import { writePackageSecrets } from "@/lib/openbao";
|
import { writePackageSecrets } from "@/lib/openbao";
|
||||||
|
import { createRoute as createRelayRoute } from "@/lib/threema-relay";
|
||||||
import {
|
import {
|
||||||
getDefaultSoulMd,
|
getDefaultSoulMd,
|
||||||
getDefaultAgentsMd,
|
getDefaultAgentsMd,
|
||||||
@@ -88,23 +86,6 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await patchTenantSpec(tenantRequest.tenantName, { suspend: false });
|
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.
|
// Clear the annotation that pauses the operator's 60-day TTL.
|
||||||
// Best-effort — annotation cleanup is also done by the operator
|
// Best-effort — annotation cleanup is also done by the operator
|
||||||
// when it sees suspend=false on the next reconcile (it clears
|
// when it sees suspend=false on the next reconcile (it clears
|
||||||
@@ -197,6 +178,29 @@ export async function POST(
|
|||||||
? tenantRequest.contactName || "Assistant"
|
? tenantRequest.contactName || "Assistant"
|
||||||
: tenantRequest.companyName;
|
: 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(
|
await createTenant(
|
||||||
tenantName,
|
tenantName,
|
||||||
{
|
{
|
||||||
@@ -204,6 +208,9 @@ export async function POST(
|
|||||||
agentName: tenantRequest.agentName,
|
agentName: tenantRequest.agentName,
|
||||||
packages,
|
packages,
|
||||||
workspaceFiles,
|
workspaceFiles,
|
||||||
|
...(Object.keys(filteredChannelUsers).length > 0
|
||||||
|
? { channelUsers: filteredChannelUsers }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"pieced.ch/zitadel-org-id": tenantRequest.zitadelOrgId,
|
"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
|
// Threema: register relay routes for each id the customer
|
||||||
// package state. Anchored at "now" rather than the CR's
|
// entered. Best-effort — a route failure doesn't unwind the
|
||||||
// creationTimestamp because we don't get the timestamp back from
|
// tenant creation (admin can retry from the tenant page later).
|
||||||
// createTenant — the few-millisecond skew vs the CR's actual
|
// The Threema package itself isn't enabled on the tenant until
|
||||||
// creationTimestamp is irrelevant for monthly billing.
|
// the customer toggles it from the tenant detail page (which
|
||||||
//
|
// also mints the per-tenant token); the routes here pre-warm
|
||||||
// Best-effort: tracking failures must never block provisioning.
|
// the relay so the first toggle works without re-typing the id.
|
||||||
// The backfill helper can repair any gaps later if needed.
|
if (
|
||||||
const billingAnchor = new Date();
|
packages.includes("threema") &&
|
||||||
try {
|
filteredChannelUsers.threema &&
|
||||||
await recordTenantCreated(
|
filteredChannelUsers.threema.length > 0
|
||||||
tenantName,
|
) {
|
||||||
tenantRequest.zitadelOrgId,
|
for (const tid of filteredChannelUsers.threema) {
|
||||||
billingAnchor
|
try {
|
||||||
);
|
const res = await createRelayRoute(tenantName, tid);
|
||||||
await recordSkillEvents(
|
if (!res.ok) {
|
||||||
tenantName,
|
console.warn(
|
||||||
tenantRequest.zitadelOrgId,
|
`[approve] Threema route create for tenant=${tenantName} id=${tid} returned not-ok: ${res.message}`
|
||||||
packages,
|
);
|
||||||
[],
|
}
|
||||||
billingAnchor
|
} catch (e) {
|
||||||
);
|
console.error(
|
||||||
} catch (e) {
|
`[approve] Threema route create threw for tenant=${tenantName} id=${tid}:`,
|
||||||
console.error(
|
e
|
||||||
"billing: failed to record tenant creation / initial skill events:",
|
);
|
||||||
e
|
}
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 5: Update request status — clear admin notes on re-approval
|
// Step 5: Update request status — clear admin notes on re-approval
|
||||||
|
|||||||
@@ -208,6 +208,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const input: OnboardingInput & {
|
const input: OnboardingInput & {
|
||||||
packageSecrets?: Record<string, Record<string, string>>;
|
packageSecrets?: Record<string, Record<string, string>>;
|
||||||
|
channelUsers?: Record<string, string[]>;
|
||||||
} = parsed.data;
|
} = parsed.data;
|
||||||
|
|
||||||
// Look up an existing approved request for this org to inherit
|
// Look up an existing approved request for this org to inherit
|
||||||
@@ -443,6 +444,7 @@ export async function POST(request: Request) {
|
|||||||
billingNotes,
|
billingNotes,
|
||||||
encryptedSecrets,
|
encryptedSecrets,
|
||||||
isPersonal,
|
isPersonal,
|
||||||
|
channelUsers: input.channelUsers ?? {},
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await sendAdminNotificationEmail(
|
await sendAdminNotificationEmail(
|
||||||
@@ -487,6 +489,7 @@ export async function POST(request: Request) {
|
|||||||
billingNotes,
|
billingNotes,
|
||||||
encryptedSecrets,
|
encryptedSecrets,
|
||||||
isPersonal,
|
isPersonal,
|
||||||
|
channelUsers: input.channelUsers ?? {},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Derive the future tenant_name — needed on the invoice line so
|
// Derive the future tenant_name — needed on the invoice line so
|
||||||
|
|||||||
@@ -255,6 +255,14 @@ export function OnboardingWizard({
|
|||||||
const [disclaimerAccepted, setDisclaimerAccepted] = useState<
|
const [disclaimerAccepted, setDisclaimerAccepted] = useState<
|
||||||
Record<string, boolean>
|
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
|
// Fetch DB-stored defaults on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -474,6 +482,20 @@ export function OnboardingWizard({
|
|||||||
})()
|
})()
|
||||||
: config;
|
: 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, {
|
const res = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -483,6 +505,10 @@ export function OnboardingWizard({
|
|||||||
Object.keys(secretsPayload).length > 0
|
Object.keys(secretsPayload).length > 0
|
||||||
? secretsPayload
|
? secretsPayload
|
||||||
: undefined,
|
: undefined,
|
||||||
|
channelUsers:
|
||||||
|
Object.keys(channelUsersPayload).length > 0
|
||||||
|
? channelUsersPayload
|
||||||
|
: undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -877,6 +903,46 @@ export function OnboardingWizard({
|
|||||||
</label>
|
</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 && (
|
{pkg.disclaimerKey && (
|
||||||
<label className="flex items-start gap-2 text-xs text-text-secondary">
|
<label className="flex items-start gap-2 text-xs text-text-secondary">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -297,17 +297,33 @@ export function PackageCard({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : canEdit ? (
|
) : canEdit ? (
|
||||||
<button
|
<div className="ml-auto flex items-center gap-2">
|
||||||
onClick={enabled ? handleDisable : handleEnable}
|
{/* Phase 9b: re-open the Threema info popup at any time
|
||||||
disabled={saving}
|
while Threema is enabled. The popup auto-opens after
|
||||||
className={`ml-auto rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
|
a fresh enable; this button lets the customer see the
|
||||||
enabled
|
QR + bot ID again without having to disable + re-enable. */}
|
||||||
? "bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2"
|
{pkg.id === "threema" && enabled && (
|
||||||
: "bg-accent text-surface-0 hover:bg-accent-dim shadow-lg shadow-accent/20"
|
<button
|
||||||
} disabled:opacity-50`}
|
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"
|
||||||
{saving ? "…" : enabled ? t("packages.disable") : t("packages.enable")}
|
title={t("packages.showInfoTitle")}
|
||||||
</button>
|
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
|
// Slice 5: read-only viewers see a static badge instead of a
|
||||||
// toggle. The status badge above the divider already conveys
|
// toggle. The status badge above the divider already conveys
|
||||||
|
|||||||
@@ -105,6 +105,14 @@ const MIGRATION_SQL = `
|
|||||||
ON tenant_requests(setup_invoice_id)
|
ON tenant_requests(setup_invoice_id)
|
||||||
WHERE setup_invoice_id IS NOT NULL;
|
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.
|
-- Feature 6: free-form customer note attached to the request.
|
||||||
-- Currently surfaced only by resume requests (where the customer
|
-- Currently surfaced only by resume requests (where the customer
|
||||||
-- explains why they want reactivation), but the column is generic
|
-- 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,
|
(zitadel_org_id, zitadel_user_id, company_name, instance_name,
|
||||||
contact_name, contact_email, agent_name, soul_md, agents_md,
|
contact_name, contact_email, agent_name, soul_md, agents_md,
|
||||||
packages, billing_address, billing_notes, encrypted_secrets,
|
packages, billing_address, billing_notes, encrypted_secrets,
|
||||||
is_personal)
|
is_personal, channel_users)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15::jsonb)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
params.zitadelOrgId,
|
params.zitadelOrgId,
|
||||||
@@ -914,6 +922,7 @@ export async function createTenantRequest(
|
|||||||
params.billingNotes,
|
params.billingNotes,
|
||||||
params.encryptedSecrets ?? null,
|
params.encryptedSecrets ?? null,
|
||||||
params.isPersonal ?? false,
|
params.isPersonal ?? false,
|
||||||
|
JSON.stringify(params.channelUsers ?? {}),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
return mapRow(result.rows[0]);
|
return mapRow(result.rows[0]);
|
||||||
@@ -1449,6 +1458,7 @@ function mapRow(row: any): TenantRequest {
|
|||||||
adminNotes: row.admin_notes,
|
adminNotes: row.admin_notes,
|
||||||
tenantName: row.tenant_name,
|
tenantName: row.tenant_name,
|
||||||
setupInvoiceId: row.setup_invoice_id ?? null,
|
setupInvoiceId: row.setup_invoice_id ?? null,
|
||||||
|
channelUsers: (row.channel_users ?? {}) as Record<string, string[]>,
|
||||||
encryptedSecrets: row.encrypted_secrets ?? null,
|
encryptedSecrets: row.encrypted_secrets ?? null,
|
||||||
isPersonal: row.is_personal ?? false,
|
isPersonal: row.is_personal ?? false,
|
||||||
dismissedAt:
|
dismissedAt:
|
||||||
@@ -4235,6 +4245,7 @@ export async function createTenantRequestPendingPayment(params: {
|
|||||||
billingNotes?: string;
|
billingNotes?: string;
|
||||||
encryptedSecrets?: Buffer | null;
|
encryptedSecrets?: Buffer | null;
|
||||||
isPersonal: boolean;
|
isPersonal: boolean;
|
||||||
|
channelUsers?: Record<string, string[]>;
|
||||||
}): Promise<TenantRequest> {
|
}): Promise<TenantRequest> {
|
||||||
await ensureSchema();
|
await ensureSchema();
|
||||||
const result = await getPool().query(
|
const result = await getPool().query(
|
||||||
@@ -4244,10 +4255,11 @@ export async function createTenantRequestPendingPayment(params: {
|
|||||||
agent_name, soul_md, agents_md, packages,
|
agent_name, soul_md, agents_md, packages,
|
||||||
billing_address, billing_notes,
|
billing_address, billing_notes,
|
||||||
encrypted_secrets, is_personal,
|
encrypted_secrets, is_personal,
|
||||||
|
channel_users,
|
||||||
status, request_type
|
status, request_type
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12,
|
$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 *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
@@ -4265,6 +4277,7 @@ export async function createTenantRequestPendingPayment(params: {
|
|||||||
params.billingNotes ?? null,
|
params.billingNotes ?? null,
|
||||||
params.encryptedSecrets ?? null,
|
params.encryptedSecrets ?? null,
|
||||||
params.isPersonal,
|
params.isPersonal,
|
||||||
|
JSON.stringify(params.channelUsers ?? {}),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
return mapRow(result.rows[0]);
|
return mapRow(result.rows[0]);
|
||||||
|
|||||||
@@ -84,6 +84,21 @@ export interface PackageDef {
|
|||||||
* stays opt-in.
|
* stays opt-in.
|
||||||
*/
|
*/
|
||||||
recommended?: boolean;
|
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[] = [
|
export const PACKAGE_CATALOG: PackageDef[] = [
|
||||||
@@ -137,6 +152,7 @@ export const PACKAGE_CATALOG: PackageDef[] = [
|
|||||||
instructionsKey: "packages.telegram.instructions",
|
instructionsKey: "packages.telegram.instructions",
|
||||||
disclaimerKey: "packages.telegram.disclaimer",
|
disclaimerKey: "packages.telegram.disclaimer",
|
||||||
category: "channel",
|
category: "channel",
|
||||||
|
collectsChannelUserId: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "discord",
|
id: "discord",
|
||||||
@@ -166,6 +182,7 @@ export const PACKAGE_CATALOG: PackageDef[] = [
|
|||||||
instructionsKey: "packages.discord.instructions",
|
instructionsKey: "packages.discord.instructions",
|
||||||
disclaimerKey: "packages.discord.disclaimer",
|
disclaimerKey: "packages.discord.disclaimer",
|
||||||
category: "channel",
|
category: "channel",
|
||||||
|
collectsChannelUserId: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "threema",
|
id: "threema",
|
||||||
@@ -182,6 +199,7 @@ export const PACKAGE_CATALOG: PackageDef[] = [
|
|||||||
disclaimerKey: "packages.threema.disclaimer",
|
disclaimerKey: "packages.threema.disclaimer",
|
||||||
category: "channel",
|
category: "channel",
|
||||||
recommended: true,
|
recommended: true,
|
||||||
|
collectsChannelUserId: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -152,6 +152,12 @@ export const onboardingSchema = z.object({
|
|||||||
packageSecrets: z
|
packageSecrets: z
|
||||||
.record(z.string(), z.record(z.string(), z.string()))
|
.record(z.string(), z.record(z.string(), z.string()))
|
||||||
.optional(),
|
.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(),
|
billingAddress: billingAddressSchema.optional(),
|
||||||
billingNotes: z.string().max(2_000).optional(),
|
billingNotes: z.string().max(2_000).optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -126,7 +126,23 @@
|
|||||||
"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 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.",
|
"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",
|
"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": {
|
"dashboard": {
|
||||||
"title": "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.",
|
"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",
|
"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": {
|
"admin": {
|
||||||
"title": "Plattform-Admin",
|
"title": "Plattform-Admin",
|
||||||
|
|||||||
@@ -126,7 +126,23 @@
|
|||||||
"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 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.",
|
"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",
|
"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": {
|
"dashboard": {
|
||||||
"title": "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.",
|
"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",
|
"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": {
|
"admin": {
|
||||||
"title": "Platform Admin",
|
"title": "Platform Admin",
|
||||||
|
|||||||
@@ -126,7 +126,23 @@
|
|||||||
"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 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.",
|
"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",
|
"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": {
|
"dashboard": {
|
||||||
"title": "Tableau de bord",
|
"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.",
|
"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",
|
"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": {
|
"admin": {
|
||||||
"title": "Admin plateforme",
|
"title": "Admin plateforme",
|
||||||
|
|||||||
@@ -126,7 +126,23 @@
|
|||||||
"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 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.",
|
"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",
|
"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": {
|
"dashboard": {
|
||||||
"title": "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.",
|
"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",
|
"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": {
|
"admin": {
|
||||||
"title": "Admin piattaforma",
|
"title": "Admin piattaforma",
|
||||||
|
|||||||
@@ -298,6 +298,16 @@ export interface TenantRequest {
|
|||||||
* rejection refunds this invoice via the existing refund flow.
|
* rejection refunds this invoice via the existing refund flow.
|
||||||
*/
|
*/
|
||||||
setupInvoiceId?: string | null;
|
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;
|
encryptedSecrets?: Buffer | null;
|
||||||
/**
|
/**
|
||||||
* Slice 4: true for personal accounts. Drives CR-naming (`p-{suffix}`
|
* Slice 4: true for personal accounts. Drives CR-naming (`p-{suffix}`
|
||||||
@@ -361,6 +371,14 @@ export interface OnboardingInput {
|
|||||||
*/
|
*/
|
||||||
billingAddress?: BillingAddress;
|
billingAddress?: BillingAddress;
|
||||||
billingNotes?: string;
|
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[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user