feat: billing/invoice details (full name, address, phone) under Settings > Billing (v0.76.0)

Adds a new user_billing_details table (1:1 with users, separate from the display name) with a form and GET/PUT API route. Full name is required by the form/API; address and phone stay optional. Not wired into Stripe invoicing yet — just captured for future use.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-24 11:35:26 +02:00
parent 04a911b431
commit 003e8abe22
15 changed files with 6440 additions and 5 deletions
@@ -0,0 +1,15 @@
CREATE TABLE "user_billing_details" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"full_name" text,
"address_line1" text,
"address_line2" text,
"city" text,
"postal_code" text,
"country" text,
"phone" text,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "user_billing_details_user_id_unique" UNIQUE("user_id")
);
--> statement-breakpoint
ALTER TABLE "user_billing_details" ADD CONSTRAINT "user_billing_details_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
File diff suppressed because it is too large Load Diff
@@ -442,6 +442,13 @@
"when": 1784805788971,
"tag": "0062_mean_ikaris",
"breakpoints": true
},
{
"idx": 63,
"version": "7",
"when": 1784885406472,
"tag": "0063_sharp_stone_men",
"breakpoints": true
}
]
}
+24
View File
@@ -1,4 +1,6 @@
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
import { users } from "./users";
// Dedup log for inbound Stripe webhook events. Stripe may redeliver the same
// event within its retry/tolerance window; we insert the event id here
@@ -9,3 +11,25 @@ export const processedStripeEvents = pgTable("processed_stripe_events", {
type: text("type").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
// Billing/invoice details, separate from the display `name` on `users` —
// fullName here is the legal/billing name used on invoices. All columns are
// nullable at the DB level (existing users have none of this filled in yet);
// fullName being "mandatory" is enforced by the API/form, not a NOT NULL
// constraint, since we can't backfill it for every existing user.
export const userBillingDetails = pgTable("user_billing_details", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }).unique(),
fullName: text("full_name"),
addressLine1: text("address_line1"),
addressLine2: text("address_line2"),
city: text("city"),
postalCode: text("postal_code"),
country: text("country"),
phone: text("phone"),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const userBillingDetailsRelations = relations(userBillingDetails, ({ one }) => ({
user: one(users, { fields: [userBillingDetails.userId], references: [users.id] }),
}));