Files
pieced-portal/src/lib/validation.ts
admin 392b0991a5
Some checks failed
Build and Push / build (push) Failing after 41s
Billing rework
2026-05-02 00:04:23 +02:00

178 lines
6.9 KiB
TypeScript

import { z } from "zod";
/**
* Shared validation schemas for the onboarding wizard and the
* registration form. Both client and server import from here so the
* rules can't drift apart.
*
* Bug 12 motivation: until now, all wizard fields could be empty and
* still submit — the server schema in `/api/onboarding` had every
* billing field optional, and the client did no validation at all.
* Required fields are now declared once, here, and used in three
* places:
* 1. The wizard's per-step `validateStep()` to gate `goNext()`.
* 2. The wizard's submit handler to render inline errors.
* 3. The server route's `safeParse()` so the rules are also
* enforced on direct API calls.
*
* Don't mix UX-only state (e.g. "did the user touch this field yet")
* into these schemas — that belongs in the wizard's render layer.
* These schemas describe what the data has to look like, not the
* progressive-disclosure rules.
*/
// ISO-3166-1 alpha-2 codes accepted in the country dropdown. DACH+
// neighbours: Switzerland, Germany, Austria, France, Italy, plus
// Liechtenstein (Swiss customers with LI billing addresses are common
// enough to include without inflating the list). Add to this set when
// expanding into new markets.
export const SUPPORTED_COUNTRIES = ["CH", "DE", "AT", "FR", "IT", "LI"] as const;
export type SupportedCountry = (typeof SUPPORTED_COUNTRIES)[number];
/**
* Country-specific postal-code patterns. Bug 33: previously a postal
* code could be anything (e.g. "abc"), which broke invoicing.
*
* Patterns are deliberately conservative — they reject obviously wrong
* input but don't try to be exhaustive valid-range checkers (e.g. CH
* codes are 1000-9999 in practice but \d{4} accepts 0000; the post
* office will reject downstream if it matters). If a future country
* has multi-format codes (e.g. UK postcodes with the inner-outer
* structure), add it as a regex here rather than trying to fit
* every country into the same shape.
*/
const POSTAL_CODE_PATTERNS: Record<SupportedCountry, RegExp> = {
CH: /^\d{4}$/,
DE: /^\d{5}$/,
AT: /^\d{4}$/,
FR: /^\d{5}$/,
IT: /^\d{5}$/,
LI: /^\d{4}$/,
};
/**
* Postal-code expectation in human terms — used in error messages so
* the user gets a useful hint ("expected 4 digits") rather than just
* a regex failure. Keep in sync with POSTAL_CODE_PATTERNS.
*/
const POSTAL_CODE_HINTS: Record<SupportedCountry, string> = {
CH: "4 digits",
DE: "5 digits",
AT: "4 digits",
FR: "5 digits",
IT: "5 digits",
LI: "4 digits",
};
/**
* Billing address — every field required at minimum non-empty length.
* Postal code is validated against the chosen country (Bug 33). Country
* is a fixed enum to prevent free-text typos that break invoicing.
*
* `superRefine` is the right hook here because we need to look at two
* fields (country + postalCode) together. The error path is set on
* `postalCode` so the wizard renders the inline error under the right
* input rather than at the form root.
*/
export const billingAddressSchema = z
.object({
// Company line is structurally optional — personal accounts leave it
// empty by design (Bug 2). Server-side, the wizard's UI hides the
// field for personals; the schema just doesn't require it.
company: z.string().trim().max(100).optional().default(""),
street: z.string().trim().min(1, "required").max(200),
postalCode: z.string().trim().min(1, "required").max(12),
city: z.string().trim().min(1, "required").max(100),
country: z.enum(SUPPORTED_COUNTRIES, {
message: "Please choose a country from the list",
}),
// Bug 35: VAT identifier. Required for company customers (B2B);
// omitted entirely for personal customers (B2C — private
// individuals don't have a VAT number). The schema marks it
// optional because the same schema is used for both flows;
// company-vs-personal enforcement happens at the API layer where
// `user.isPersonal` is known.
vatNumber: z.string().trim().max(50).optional(),
})
.superRefine((data, ctx) => {
const pattern = POSTAL_CODE_PATTERNS[data.country];
if (!pattern.test(data.postalCode)) {
ctx.addIssue({
code: "custom",
path: ["postalCode"],
message: `Invalid postal code (expected ${POSTAL_CODE_HINTS[data.country]})`,
});
}
});
export type BillingAddressInput = z.infer<typeof billingAddressSchema>;
/**
* Per-step schemas for progressive validation. Each step validates only
* the fields visible up to that point, so the user gets feedback at the
* step they're on rather than at the end.
*
* The `welcome` step has nothing to validate.
* The `configure` step requires a non-empty agentName.
* The `billing` step requires a complete billing address (with the
* optional company line).
* The `confirm` step is the final submission and validates the union.
*/
export const configureStepSchema = z.object({
agentName: z.string().trim().min(1, "required").max(50),
});
export const billingStepSchema = z.object({
billingAddress: billingAddressSchema,
});
/**
* Full onboarding payload. Used by the API route and by the wizard's
* submit handler. `packageSecrets` is a free-shape map that gets
* encrypted by the server before it touches the DB.
*
* Bug 35: `billingAddress` is now optional at the schema level. The
* wizard omits it entirely when the org already has an `org_billing`
* record. The API enforces "billing must exist by the end" by either
* looking up the existing org_billing row OR validating the supplied
* payload — neither path can be skipped without a 400.
*/
export const onboardingSchema = z.object({
instanceName: z
.string()
.trim()
.max(80)
.optional()
// Empty string from a form input → undefined so the DB stores NULL.
.transform((v) => (v && v.length > 0 ? v : undefined)),
agentName: z.string().trim().min(1, "required").max(50),
soulMd: z.string().max(10_000).optional(),
agentsMd: z.string().max(10_000).optional(),
packages: z.array(z.string()).optional(),
packageSecrets: z
.record(z.string(), z.record(z.string(), z.string()))
.optional(),
billingAddress: billingAddressSchema.optional(),
billingNotes: z.string().max(2_000).optional(),
});
export type OnboardingPayload = z.infer<typeof onboardingSchema>;
/**
* Helper: flatten a Zod error into a flat field-path → message map.
* The wizard uses this to look up errors per input by their path.
*
* Returns `{}` on success (i.e. caller shouldn't call this on a parsed
* value; only on `safeParse(...).error`). Kept here rather than inline
* so both the wizard and any future field-level form (e.g. settings
* page reusing billingAddressSchema) can share it.
*/
export function fieldErrors(err: z.ZodError): Record<string, string> {
const out: Record<string, string> = {};
for (const issue of err.issues) {
const key = issue.path.join(".");
if (!(key in out)) out[key] = issue.message;
}
return out;
}