Add billing assistant settings and verification API
This commit is contained in:
parent
458ff3d60e
commit
44f55f9d2f
11 changed files with 709 additions and 0 deletions
|
|
@ -32,6 +32,7 @@ OPENAI_TRANSLATIONS_KEY=
|
||||||
HETZNER_API_TOKEN=
|
HETZNER_API_TOKEN=
|
||||||
HCLOUD_TOKEN=
|
HCLOUD_TOKEN=
|
||||||
HETZNER_TOKEN=
|
HETZNER_TOKEN=
|
||||||
|
N8N_BILLING_API_KEY=
|
||||||
JOKER_DYNDNS_USERNAME=
|
JOKER_DYNDNS_USERNAME=
|
||||||
JOKER_DYNDNS_PASSWORD=
|
JOKER_DYNDNS_PASSWORD=
|
||||||
REGISTRY_USERNAME=
|
REGISTRY_USERNAME=
|
||||||
|
|
|
||||||
137
app/api/integrations/billing/verify/route.ts
Normal file
137
app/api/integrations/billing/verify/route.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { ListingStatus, UserStatus } from '@prisma/client';
|
||||||
|
import { prisma } from '../../../../../lib/prisma';
|
||||||
|
import { loadN8nBillingApiKey } from '../../../../../lib/apiKeys';
|
||||||
|
import { resolveBillingDetails } from '../../../../../lib/billing';
|
||||||
|
|
||||||
|
function pickTranslation(translations: { slug: string; locale: string }[]) {
|
||||||
|
return translations.find((t) => t.locale === 'en') || translations.find((t) => t.locale === 'fi') || translations[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractApiKey(req: Request) {
|
||||||
|
const headerKey = req.headers.get('x-api-key');
|
||||||
|
if (headerKey) return headerKey.trim();
|
||||||
|
const auth = req.headers.get('authorization');
|
||||||
|
if (auth && auth.toLowerCase().startsWith('bearer ')) {
|
||||||
|
return auth.slice(7).trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const expectedKey = loadN8nBillingApiKey();
|
||||||
|
if (!expectedKey) {
|
||||||
|
return NextResponse.json({ error: 'Billing API key missing' }, { status: 500 });
|
||||||
|
}
|
||||||
|
const providedKey = extractApiKey(req);
|
||||||
|
if (!providedKey || providedKey !== expectedKey) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: any;
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const listingId = typeof body.listingId === 'string' ? body.listingId : undefined;
|
||||||
|
const listingSlug = typeof body.listingSlug === 'string' ? body.listingSlug.trim() : undefined;
|
||||||
|
const ownerEmailRaw = typeof body.ownerEmail === 'string' ? body.ownerEmail.trim() : undefined;
|
||||||
|
const ownerEmail = ownerEmailRaw ? ownerEmailRaw.toLowerCase() : undefined;
|
||||||
|
|
||||||
|
if (!listingId && !listingSlug && !ownerEmail) {
|
||||||
|
return NextResponse.json({ error: 'Provide listingId, listingSlug, or ownerEmail' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let listing: any = null;
|
||||||
|
if (listingId || listingSlug) {
|
||||||
|
const listings = await prisma.listing.findMany({
|
||||||
|
where: {
|
||||||
|
removedAt: null,
|
||||||
|
status: { not: ListingStatus.REMOVED },
|
||||||
|
id: listingId ?? undefined,
|
||||||
|
translations: listingSlug ? { some: { slug: listingSlug } } : undefined,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
owner: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
status: true,
|
||||||
|
approvedAt: true,
|
||||||
|
emailVerifiedAt: true,
|
||||||
|
billingEmailsEnabled: true,
|
||||||
|
billingAccountName: true,
|
||||||
|
billingIban: true,
|
||||||
|
billingIncludeVatLine: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
translations: { select: { slug: true, locale: true } },
|
||||||
|
},
|
||||||
|
take: listingSlug ? 2 : 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (listingSlug && listings.length > 1) {
|
||||||
|
return NextResponse.json({ error: 'Listing slug is ambiguous; provide listingId' }, { status: 400 });
|
||||||
|
}
|
||||||
|
listing = listings[0] ?? null;
|
||||||
|
if (!listing && listingId) {
|
||||||
|
return NextResponse.json({ error: 'Listing not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (!listing && listingSlug) {
|
||||||
|
return NextResponse.json({ error: 'Listing not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let owner = listing?.owner ?? null;
|
||||||
|
if (!owner && ownerEmail) {
|
||||||
|
owner = await prisma.user.findFirst({
|
||||||
|
where: { email: { equals: ownerEmail, mode: 'insensitive' } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
status: true,
|
||||||
|
approvedAt: true,
|
||||||
|
emailVerifiedAt: true,
|
||||||
|
billingEmailsEnabled: true,
|
||||||
|
billingAccountName: true,
|
||||||
|
billingIban: true,
|
||||||
|
billingIncludeVatLine: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!owner) {
|
||||||
|
return NextResponse.json({ enabled: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownerReady = owner.status === UserStatus.ACTIVE && owner.approvedAt && owner.emailVerifiedAt;
|
||||||
|
if (!ownerReady || !owner.billingEmailsEnabled) {
|
||||||
|
return NextResponse.json({ enabled: false, owner: { id: owner.id, email: owner.email } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const billing = resolveBillingDetails(owner, listing ?? undefined);
|
||||||
|
const enabled = Boolean(billing.accountName && billing.iban);
|
||||||
|
const listingHasOverride =
|
||||||
|
listing &&
|
||||||
|
(listing.billingAccountName !== null && listing.billingAccountName !== undefined ||
|
||||||
|
listing.billingIban !== null && listing.billingIban !== undefined ||
|
||||||
|
listing.billingIncludeVatLine !== null && listing.billingIncludeVatLine !== undefined);
|
||||||
|
const source = listingHasOverride ? 'listing' : 'user';
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
enabled,
|
||||||
|
owner: { id: owner.id, email: owner.email },
|
||||||
|
listing: listing
|
||||||
|
? {
|
||||||
|
id: listing.id,
|
||||||
|
slug: pickTranslation(listing.translations)?.slug ?? listing.translations[0]?.slug ?? null,
|
||||||
|
status: listing.status,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
billing: enabled ? { ...billing, source } : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
215
app/api/me/billing/route.ts
Normal file
215
app/api/me/billing/route.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { UserStatus } from '@prisma/client';
|
||||||
|
import { prisma } from '../../../../lib/prisma';
|
||||||
|
import { requireAuth } from '../../../../lib/jwt';
|
||||||
|
import { normalizeIban, normalizeNullableBoolean, normalizeOptionalString } from '../../../../lib/billing';
|
||||||
|
|
||||||
|
function pickTranslation(translations: { title: string; slug: string; locale: string }[]) {
|
||||||
|
return translations.find((t) => t.locale === 'en') || translations.find((t) => t.locale === 'fi') || translations[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadState(userId: string) {
|
||||||
|
const [user, listings] = await Promise.all([
|
||||||
|
prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
status: true,
|
||||||
|
billingEmailsEnabled: true,
|
||||||
|
billingAccountName: true,
|
||||||
|
billingIban: true,
|
||||||
|
billingIncludeVatLine: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.listing.findMany({
|
||||||
|
where: { ownerId: userId, removedAt: null },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
billingAccountName: true,
|
||||||
|
billingIban: true,
|
||||||
|
billingIncludeVatLine: true,
|
||||||
|
translations: { select: { title: true, slug: true, locale: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return { user, listings };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAccountName(name: string | null | undefined) {
|
||||||
|
if (name && name.length > 120) {
|
||||||
|
return 'Account owner name is too long';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateIban(iban: string | null | undefined) {
|
||||||
|
if (iban && !/^[A-Z0-9]{8,34}$/.test(iban)) {
|
||||||
|
return 'IBAN must be 8-34 alphanumeric characters';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeListingOverrides(body: any) {
|
||||||
|
const overridesRaw = Array.isArray(body?.listings) ? body.listings : [];
|
||||||
|
return overridesRaw
|
||||||
|
.map((item: any) => ({
|
||||||
|
id: typeof item.id === 'string' ? item.id : null,
|
||||||
|
accountName: normalizeOptionalString(item.accountName),
|
||||||
|
iban: normalizeIban(item.iban),
|
||||||
|
includeVatLine: normalizeNullableBoolean(item.includeVatLine),
|
||||||
|
}))
|
||||||
|
.filter((o) => o.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResponsePayload(user: NonNullable<Awaited<ReturnType<typeof loadState>>['user']>, listings: Awaited<ReturnType<typeof loadState>>['listings']) {
|
||||||
|
return {
|
||||||
|
settings: {
|
||||||
|
enabled: user.billingEmailsEnabled,
|
||||||
|
accountName: user.billingAccountName,
|
||||||
|
iban: user.billingIban,
|
||||||
|
includeVatLine: user.billingIncludeVatLine,
|
||||||
|
},
|
||||||
|
listings: listings.map((listing) => {
|
||||||
|
const translation = pickTranslation(listing.translations);
|
||||||
|
return {
|
||||||
|
id: listing.id,
|
||||||
|
title: translation?.title ?? 'Listing',
|
||||||
|
slug: translation?.slug ?? '',
|
||||||
|
locale: translation?.locale ?? 'en',
|
||||||
|
billingAccountName: listing.billingAccountName,
|
||||||
|
billingIban: listing.billingIban,
|
||||||
|
billingIncludeVatLine: listing.billingIncludeVatLine,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
const session = await requireAuth(req);
|
||||||
|
const { user, listings } = await loadState(session.userId);
|
||||||
|
if (!user || user.status !== UserStatus.ACTIVE) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(buildResponsePayload(user, listings));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Billing settings fetch failed', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to load billing settings' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(req: Request) {
|
||||||
|
let body: any;
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await requireAuth(req);
|
||||||
|
const { user, listings } = await loadState(session.userId);
|
||||||
|
if (!user || user.status !== UserStatus.ACTIVE) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabled = body.enabled === undefined ? undefined : Boolean(body.enabled);
|
||||||
|
const accountName = normalizeOptionalString(body.accountName);
|
||||||
|
const iban = normalizeIban(body.iban);
|
||||||
|
const includeVatLine = body.includeVatLine === undefined ? undefined : Boolean(body.includeVatLine);
|
||||||
|
const listingOverrides = normalizeListingOverrides(body);
|
||||||
|
|
||||||
|
const errors = [validateAccountName(accountName), validateIban(iban)].filter(Boolean) as string[];
|
||||||
|
for (const override of listingOverrides) {
|
||||||
|
const nameError = validateAccountName(override.accountName);
|
||||||
|
const ibanError = validateIban(override.iban);
|
||||||
|
if (nameError) errors.push(`${nameError} for listing ${override.id}`);
|
||||||
|
if (ibanError) errors.push(`${ibanError} for listing ${override.id}`);
|
||||||
|
}
|
||||||
|
if (errors.length) {
|
||||||
|
return NextResponse.json({ error: errors.join('; ') }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userUpdates: any = {};
|
||||||
|
if (enabled !== undefined) userUpdates.billingEmailsEnabled = enabled;
|
||||||
|
if (accountName !== undefined) userUpdates.billingAccountName = accountName;
|
||||||
|
if (iban !== undefined) userUpdates.billingIban = iban;
|
||||||
|
if (includeVatLine !== undefined) userUpdates.billingIncludeVatLine = includeVatLine;
|
||||||
|
|
||||||
|
const listingMap = new Map(listings.map((l) => [l.id, l]));
|
||||||
|
const listingUpdates: { id: string; data: Record<string, any> }[] = [];
|
||||||
|
listingOverrides.forEach((override) => {
|
||||||
|
if (!listingMap.has(override.id!)) return;
|
||||||
|
const data: Record<string, any> = {};
|
||||||
|
if (override.accountName !== undefined) data.billingAccountName = override.accountName;
|
||||||
|
if (override.iban !== undefined) data.billingIban = override.iban;
|
||||||
|
if (override.includeVatLine !== undefined) data.billingIncludeVatLine = override.includeVatLine;
|
||||||
|
if (Object.keys(data).length) {
|
||||||
|
listingUpdates.push({ id: override.id!, data });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const targetEnabled = enabled ?? user.billingEmailsEnabled;
|
||||||
|
if (targetEnabled) {
|
||||||
|
const targetAccountName = accountName !== undefined ? accountName : user.billingAccountName;
|
||||||
|
const targetIban = iban !== undefined ? iban : user.billingIban;
|
||||||
|
const hasGlobalDetails = Boolean(targetAccountName && targetIban);
|
||||||
|
|
||||||
|
const missingFor: string[] = [];
|
||||||
|
listings.forEach((listing) => {
|
||||||
|
const override = listingUpdates.find((o) => o.id === listing.id);
|
||||||
|
const effectiveAccountName =
|
||||||
|
override?.data.billingAccountName !== undefined
|
||||||
|
? override.data.billingAccountName
|
||||||
|
: listing.billingAccountName ?? targetAccountName;
|
||||||
|
const effectiveIban =
|
||||||
|
override?.data.billingIban !== undefined ? override.data.billingIban : listing.billingIban ?? targetIban;
|
||||||
|
if (!effectiveAccountName || !effectiveIban) {
|
||||||
|
const t = pickTranslation(listing.translations);
|
||||||
|
missingFor.push(t?.slug || listing.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasGlobalDetails && missingFor.length) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Provide billing account name and IBAN globally or per listing (missing for: ${missingFor.join(', ')})` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!hasGlobalDetails && listings.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Add a billing account name and IBAN before enabling the billing assistant.' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tx: any[] = [];
|
||||||
|
if (Object.keys(userUpdates).length) {
|
||||||
|
tx.push(prisma.user.update({ where: { id: user.id }, data: userUpdates }));
|
||||||
|
}
|
||||||
|
listingUpdates.forEach((update) => {
|
||||||
|
tx.push(prisma.listing.update({ where: { id: update.id }, data: update.data }));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tx.length) {
|
||||||
|
await prisma.$transaction(tx);
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({ error: 'No updates provided' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user: updatedUser, listings: updatedListings } = await loadState(session.userId);
|
||||||
|
if (!updatedUser) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(buildResponsePayload(updatedUser, updatedListings));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Billing settings update failed', error);
|
||||||
|
const status = (error as any)?.message === 'Unauthorized' ? 401 : 500;
|
||||||
|
return NextResponse.json({ error: 'Failed to update billing settings' }, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
221
app/me/page.tsx
221
app/me/page.tsx
|
|
@ -4,6 +4,15 @@ import { useEffect, useState } from 'react';
|
||||||
import { useI18n } from '../components/I18nProvider';
|
import { useI18n } from '../components/I18nProvider';
|
||||||
|
|
||||||
type User = { id: string; email: string; role: string; status: string; emailVerifiedAt: string | null; approvedAt: string | null; name: string | null; phone: string | null };
|
type User = { id: string; email: string; role: string; status: string; emailVerifiedAt: string | null; approvedAt: string | null; name: string | null; phone: string | null };
|
||||||
|
type BillingListing = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
locale: string;
|
||||||
|
billingAccountName: string | null;
|
||||||
|
billingIban: string | null;
|
||||||
|
billingIncludeVatLine: boolean | null;
|
||||||
|
};
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
@ -14,6 +23,15 @@ export default function ProfilePage() {
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [billingEnabled, setBillingEnabled] = useState(false);
|
||||||
|
const [billingAccountName, setBillingAccountName] = useState('');
|
||||||
|
const [billingIban, setBillingIban] = useState('');
|
||||||
|
const [billingIncludeVatLine, setBillingIncludeVatLine] = useState(false);
|
||||||
|
const [billingListings, setBillingListings] = useState<BillingListing[]>([]);
|
||||||
|
const [billingLoading, setBillingLoading] = useState(false);
|
||||||
|
const [billingSaving, setBillingSaving] = useState(false);
|
||||||
|
const [billingMessage, setBillingMessage] = useState<string | null>(null);
|
||||||
|
const [billingError, setBillingError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/auth/me', { cache: 'no-store' })
|
fetch('/api/auth/me', { cache: 'no-store' })
|
||||||
|
|
@ -28,6 +46,38 @@ export default function ProfilePage() {
|
||||||
.catch(() => setError(t('notLoggedIn')));
|
.catch(() => setError(t('notLoggedIn')));
|
||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setBillingLoading(true);
|
||||||
|
fetch('/api/me/billing', { cache: 'no-store' })
|
||||||
|
.then(async (res) => {
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Failed');
|
||||||
|
return data;
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
setBillingEnabled(Boolean(data.settings?.enabled));
|
||||||
|
setBillingAccountName(data.settings?.accountName ?? '');
|
||||||
|
setBillingIban(data.settings?.iban ?? '');
|
||||||
|
setBillingIncludeVatLine(Boolean(data.settings?.includeVatLine));
|
||||||
|
setBillingListings(
|
||||||
|
Array.isArray(data.listings)
|
||||||
|
? data.listings.map((l: any) => ({
|
||||||
|
id: l.id,
|
||||||
|
title: l.title,
|
||||||
|
slug: l.slug,
|
||||||
|
locale: l.locale,
|
||||||
|
billingAccountName: l.billingAccountName ?? '',
|
||||||
|
billingIban: l.billingIban ?? '',
|
||||||
|
billingIncludeVatLine: l.billingIncludeVatLine ?? null,
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
setBillingError(null);
|
||||||
|
})
|
||||||
|
.catch(() => setBillingError(t('billingLoadFailed')))
|
||||||
|
.finally(() => setBillingLoading(false));
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
async function onSave(e: React.FormEvent) {
|
async function onSave(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
|
@ -54,6 +104,58 @@ export default function ProfilePage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onSaveBilling(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setBillingSaving(true);
|
||||||
|
setBillingError(null);
|
||||||
|
setBillingMessage(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/me/billing', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
enabled: billingEnabled,
|
||||||
|
accountName: billingAccountName,
|
||||||
|
iban: billingIban,
|
||||||
|
includeVatLine: billingIncludeVatLine,
|
||||||
|
listings: billingListings.map((l) => ({
|
||||||
|
id: l.id,
|
||||||
|
accountName: l.billingAccountName,
|
||||||
|
iban: l.billingIban,
|
||||||
|
includeVatLine: l.billingIncludeVatLine,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setBillingError(data.error || t('billingSaveFailed'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBillingEnabled(Boolean(data.settings?.enabled));
|
||||||
|
setBillingAccountName(data.settings?.accountName ?? '');
|
||||||
|
setBillingIban(data.settings?.iban ?? '');
|
||||||
|
setBillingIncludeVatLine(Boolean(data.settings?.includeVatLine));
|
||||||
|
setBillingListings(
|
||||||
|
Array.isArray(data.listings)
|
||||||
|
? data.listings.map((l: any) => ({
|
||||||
|
id: l.id,
|
||||||
|
title: l.title,
|
||||||
|
slug: l.slug,
|
||||||
|
locale: l.locale,
|
||||||
|
billingAccountName: l.billingAccountName ?? '',
|
||||||
|
billingIban: l.billingIban ?? '',
|
||||||
|
billingIncludeVatLine: l.billingIncludeVatLine ?? null,
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
setBillingMessage(t('billingSaved'));
|
||||||
|
} catch (err) {
|
||||||
|
setBillingError(t('billingSaveFailed'));
|
||||||
|
} finally {
|
||||||
|
setBillingSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="panel" style={{ maxWidth: 640, margin: '40px auto' }}>
|
<main className="panel" style={{ maxWidth: 640, margin: '40px auto' }}>
|
||||||
<h1>{t('myProfileTitle')}</h1>
|
<h1>{t('myProfileTitle')}</h1>
|
||||||
|
|
@ -109,6 +211,125 @@ export default function ProfilePage() {
|
||||||
{saving ? t('saving') : t('save')}
|
{saving ? t('saving') : t('save')}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
<section style={{ marginTop: 30 }}>
|
||||||
|
<h2 style={{ marginBottom: 6 }}>{t('billingSettingsTitle')}</h2>
|
||||||
|
<p style={{ color: '#444', marginBottom: 12 }}>{t('billingSettingsLead')}</p>
|
||||||
|
{billingMessage ? <p style={{ color: 'green' }}>{billingMessage}</p> : null}
|
||||||
|
{billingError ? <p style={{ color: 'red' }}>{billingError}</p> : null}
|
||||||
|
<form onSubmit={onSaveBilling} style={{ display: 'grid', gap: 14, marginTop: 8 }}>
|
||||||
|
<label style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<input type="checkbox" checked={billingEnabled} onChange={(e) => setBillingEnabled(e.target.checked)} />
|
||||||
|
<span>{t('billingEnableLabel')}</span>
|
||||||
|
</label>
|
||||||
|
{billingEnabled ? (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'grid', gap: 8 }}>
|
||||||
|
<label>
|
||||||
|
{t('billingAccountNameLabel')}
|
||||||
|
<input value={billingAccountName} onChange={(e) => setBillingAccountName(e.target.value)} placeholder="Example Oy" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{t('billingIbanLabel')}
|
||||||
|
<input value={billingIban} onChange={(e) => setBillingIban(e.target.value)} placeholder="FI00 1234 5600 0007 85" />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={billingIncludeVatLine}
|
||||||
|
onChange={(e) => setBillingIncludeVatLine(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>{t('billingIncludeVat')}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div style={{ border: '1px solid #eee', padding: 12, borderRadius: 6 }}>
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<strong>{t('billingListingsTitle')}</strong>
|
||||||
|
<div style={{ color: '#555', fontSize: 13 }}>{t('billingListingsLead')}</div>
|
||||||
|
</div>
|
||||||
|
{billingLoading ? (
|
||||||
|
<p>{t('loading')}</p>
|
||||||
|
) : billingListings.length === 0 ? (
|
||||||
|
<p style={{ color: '#666' }}>{t('billingNoListings')}</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gap: 12 }}>
|
||||||
|
{billingListings.map((listing) => {
|
||||||
|
const vatValue =
|
||||||
|
listing.billingIncludeVatLine === null || listing.billingIncludeVatLine === undefined
|
||||||
|
? 'inherit'
|
||||||
|
: listing.billingIncludeVatLine
|
||||||
|
? 'yes'
|
||||||
|
: 'no';
|
||||||
|
return (
|
||||||
|
<div key={listing.id} style={{ border: '1px solid #f0f0f0', padding: 10, borderRadius: 4 }}>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 6 }}>
|
||||||
|
{listing.title} ({listing.slug})
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gap: 8 }}>
|
||||||
|
<label>
|
||||||
|
{t('billingAccountNameLabel')}
|
||||||
|
<input
|
||||||
|
value={listing.billingAccountName ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setBillingListings((prev) =>
|
||||||
|
prev.map((l) => (l.id === listing.id ? { ...l, billingAccountName: e.target.value } : l)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder={billingAccountName || t('billingAccountPlaceholder')}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{t('billingIbanLabel')}
|
||||||
|
<input
|
||||||
|
value={listing.billingIban ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setBillingListings((prev) =>
|
||||||
|
prev.map((l) => (l.id === listing.id ? { ...l, billingIban: e.target.value } : l)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder={billingIban || t('billingIbanPlaceholder')}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{t('billingVatChoice')}
|
||||||
|
<select
|
||||||
|
value={vatValue as string}
|
||||||
|
onChange={(e) => {
|
||||||
|
const choice = e.target.value;
|
||||||
|
setBillingListings((prev) =>
|
||||||
|
prev.map((l) =>
|
||||||
|
l.id === listing.id
|
||||||
|
? {
|
||||||
|
...l,
|
||||||
|
billingIncludeVatLine: choice === 'inherit' ? null : choice === 'yes',
|
||||||
|
}
|
||||||
|
: l,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="inherit">{t('billingVatInherit')}</option>
|
||||||
|
<option value="yes">{t('billingVatYes')}</option>
|
||||||
|
<option value="no">{t('billingVatNo')}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p style={{ color: '#666' }}>{t('billingDisabledHint')}</p>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<button className="button" type="submit" disabled={billingSaving}>
|
||||||
|
{billingSaving ? t('saving') : t('save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p style={{ color: 'red' }}>{error ?? t('notLoggedIn')}</p>
|
<p style={{ color: 'red' }}>{error ?? t('notLoggedIn')}</p>
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,7 @@ flowchart LR
|
||||||
<li>From ConfigMap (public): <code>NEXT_PUBLIC_SITE_URL</code>, <code>NEXT_PUBLIC_API_BASE</code>, <code>APP_ENV</code>.</li>
|
<li>From ConfigMap (public): <code>NEXT_PUBLIC_SITE_URL</code>, <code>NEXT_PUBLIC_API_BASE</code>, <code>APP_ENV</code>.</li>
|
||||||
<li>From Secret: DB URL, AUTH_SECRET, SMTP, DKIM, etc. (materialize from <code>creds/secrets.env</code>).</li>
|
<li>From Secret: DB URL, AUTH_SECRET, SMTP, DKIM, etc. (materialize from <code>creds/secrets.env</code>).</li>
|
||||||
<li>App env resolution: <code>process.env.*</code> in Next server code.</li>
|
<li>App env resolution: <code>process.env.*</code> in Next server code.</li>
|
||||||
|
<li>n8n billing assistant: <code>N8N_BILLING_API_KEY</code> or file <code>creds/n8n-billing.key</code> protects <code>/api/integrations/billing/verify</code>.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
- `creds/secrets.enc.env`: encrypted dotenv managed by sops/age (committable).
|
- `creds/secrets.enc.env`: encrypted dotenv managed by sops/age (committable).
|
||||||
- `creds/secrets.env`: decrypted dotenv (git-ignored) produced when loading secrets; not committed.
|
- `creds/secrets.env`: decrypted dotenv (git-ignored) produced when loading secrets; not committed.
|
||||||
- Legacy plaintext secrets moved to `creds/deprecated/` for reference.
|
- Legacy plaintext secrets moved to `creds/deprecated/` for reference.
|
||||||
|
- `creds/n8n-billing.key`: API key for the billing verification endpoint (git-ignored). Can also be provided via `N8N_BILLING_API_KEY`.
|
||||||
|
|
||||||
## Editing secrets
|
## Editing secrets
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -31,6 +32,11 @@ This decrypts `creds/secrets.enc.env` to `creds/secrets.env` if needed (requires
|
||||||
- Update `.sops.yaml` recipient to the new public key.
|
- Update `.sops.yaml` recipient to the new public key.
|
||||||
- Re-encrypt: `SOPS_AGE_KEY_FILE=creds/age-key.txt sops --encrypt --in-place creds/secrets.enc.env`.
|
- Re-encrypt: `SOPS_AGE_KEY_FILE=creds/age-key.txt sops --encrypt --in-place creds/secrets.enc.env`.
|
||||||
|
|
||||||
|
## n8n billing API key
|
||||||
|
- The billing assistant verification endpoint (`/api/integrations/billing/verify`) requires an API key.
|
||||||
|
- Store it in `creds/n8n-billing.key` (git-ignored) or export `N8N_BILLING_API_KEY` via `creds/secrets.env`.
|
||||||
|
- Rotate by replacing the file/env value and restarting the app/n8n caller with the new key.
|
||||||
|
|
||||||
## Per-user age keys
|
## Per-user age keys
|
||||||
- Keys live under `creds/age/<user>.key` (git-ignored) and carry a public key in the header.
|
- Keys live under `creds/age/<user>.key` (git-ignored) and carry a public key in the header.
|
||||||
- Helper: `./scripts/manage-age-key.sh add alice` generates a key and appends the recipient to `.sops.yaml`.
|
- Helper: `./scripts/manage-age-key.sh add alice` generates a key and appends the recipient to `.sops.yaml`.
|
||||||
|
|
|
||||||
12
lib/apiKeys.ts
Normal file
12
lib/apiKeys.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export function loadN8nBillingApiKey() {
|
||||||
|
if (process.env.N8N_BILLING_API_KEY) return process.env.N8N_BILLING_API_KEY;
|
||||||
|
const keyPath = path.join(process.cwd(), 'creds', 'n8n-billing.key');
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(keyPath, 'utf8').trim();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
41
lib/billing.ts
Normal file
41
lib/billing.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import type { Listing, User } from '@prisma/client';
|
||||||
|
|
||||||
|
export type BillingConfig = {
|
||||||
|
accountName: string | null;
|
||||||
|
iban: string | null;
|
||||||
|
includeVatLine: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BillingUser = Pick<User, 'billingAccountName' | 'billingIban' | 'billingIncludeVatLine'>;
|
||||||
|
type BillingListing = Pick<Listing, 'billingAccountName' | 'billingIban' | 'billingIncludeVatLine'>;
|
||||||
|
|
||||||
|
export function normalizeOptionalString(input: unknown): string | null | undefined {
|
||||||
|
if (input === undefined) return undefined;
|
||||||
|
if (input === null) return null;
|
||||||
|
const value = String(input).trim();
|
||||||
|
return value ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeIban(input: unknown): string | null | undefined {
|
||||||
|
if (input === undefined) return undefined;
|
||||||
|
if (input === null) return null;
|
||||||
|
const value = String(input).replace(/\s+/g, '').toUpperCase();
|
||||||
|
return value ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeNullableBoolean(input: unknown): boolean | null | undefined {
|
||||||
|
if (input === undefined) return undefined;
|
||||||
|
if (input === null) return null;
|
||||||
|
return Boolean(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveBillingDetails(user: BillingUser, listing?: BillingListing | null): BillingConfig {
|
||||||
|
const accountName = listing?.billingAccountName ?? user.billingAccountName ?? null;
|
||||||
|
const iban = listing?.billingIban ?? user.billingIban ?? null;
|
||||||
|
const includeVatLine =
|
||||||
|
listing?.billingIncludeVatLine !== null && listing?.billingIncludeVatLine !== undefined
|
||||||
|
? Boolean(listing.billingIncludeVatLine)
|
||||||
|
: Boolean(user.billingIncludeVatLine);
|
||||||
|
|
||||||
|
return { accountName, iban, includeVatLine };
|
||||||
|
}
|
||||||
57
lib/i18n.ts
57
lib/i18n.ts
|
|
@ -145,6 +145,25 @@ const baseMessages = {
|
||||||
profileEmailVerified: 'Email verified',
|
profileEmailVerified: 'Email verified',
|
||||||
profileApproved: 'Approved',
|
profileApproved: 'Approved',
|
||||||
profileUpdated: 'Profile updated',
|
profileUpdated: 'Profile updated',
|
||||||
|
billingSettingsTitle: 'Billing assistant',
|
||||||
|
billingSettingsLead: 'Opt into automated billing emails via the n8n agent. Set defaults and per-listing overrides.',
|
||||||
|
billingEnableLabel: 'Enable billing assistant for my listings',
|
||||||
|
billingAccountNameLabel: 'Billing account owner name',
|
||||||
|
billingAccountPlaceholder: 'Use profile default',
|
||||||
|
billingIbanLabel: 'IBAN for payouts',
|
||||||
|
billingIbanPlaceholder: 'Use profile default',
|
||||||
|
billingIncludeVat: 'Include a VAT line on invoices',
|
||||||
|
billingListingsTitle: 'Per-listing billing',
|
||||||
|
billingListingsLead: 'Override billing details per listing; blank fields inherit your profile defaults.',
|
||||||
|
billingNoListings: 'No listings available yet.',
|
||||||
|
billingVatChoice: 'VAT line preference',
|
||||||
|
billingVatInherit: 'Use profile choice',
|
||||||
|
billingVatYes: 'Include VAT line',
|
||||||
|
billingVatNo: 'Do not include VAT line',
|
||||||
|
billingDisabledHint: 'Enable the billing assistant to manage invoice details and VAT preferences.',
|
||||||
|
billingLoadFailed: 'Failed to load billing settings.',
|
||||||
|
billingSaveFailed: 'Could not save billing settings.',
|
||||||
|
billingSaved: 'Billing settings saved.',
|
||||||
settingsSaved: 'Settings saved.',
|
settingsSaved: 'Settings saved.',
|
||||||
emailLocked: 'Email cannot be changed',
|
emailLocked: 'Email cannot be changed',
|
||||||
save: 'Save',
|
save: 'Save',
|
||||||
|
|
@ -504,6 +523,25 @@ const baseMessages = {
|
||||||
profileEmailVerified: 'Sähköposti vahvistettu',
|
profileEmailVerified: 'Sähköposti vahvistettu',
|
||||||
profileApproved: 'Hyväksytty',
|
profileApproved: 'Hyväksytty',
|
||||||
profileUpdated: 'Profiili päivitetty',
|
profileUpdated: 'Profiili päivitetty',
|
||||||
|
billingSettingsTitle: 'Laskutusavustin',
|
||||||
|
billingSettingsLead: 'Ota n8n-laskutus käyttöön halutessasi ja määritä oletus- ja kohdekohtaiset tiedot.',
|
||||||
|
billingEnableLabel: 'Ota laskutusavustin käyttöön kohteilleni',
|
||||||
|
billingAccountNameLabel: 'Tilinomistajan nimi',
|
||||||
|
billingAccountPlaceholder: 'Käytä profiilin oletusta',
|
||||||
|
billingIbanLabel: 'IBAN-maksuissa',
|
||||||
|
billingIbanPlaceholder: 'Käytä profiilin oletusta',
|
||||||
|
billingIncludeVat: 'Lisää laskulle ALV-rivi',
|
||||||
|
billingListingsTitle: 'Kohdekohtaiset asetukset',
|
||||||
|
billingListingsLead: 'Ylikirjoita tiedot kohteittain; tyhjä kenttä käyttää profiilin arvoa.',
|
||||||
|
billingNoListings: 'Ei kohteita vielä.',
|
||||||
|
billingVatChoice: 'ALV-rivin valinta',
|
||||||
|
billingVatInherit: 'Käytä profiilin asetusta',
|
||||||
|
billingVatYes: 'Lisää ALV-rivi',
|
||||||
|
billingVatNo: 'Älä lisää ALV-riviä',
|
||||||
|
billingDisabledHint: 'Ota laskutusavustin käyttöön lisätäksesi laskutustiedot ja ALV-valinnat.',
|
||||||
|
billingLoadFailed: 'Laskutusasetusten lataus epäonnistui.',
|
||||||
|
billingSaveFailed: 'Laskutusasetusten tallennus epäonnistui.',
|
||||||
|
billingSaved: 'Laskutusasetukset tallennettu.',
|
||||||
settingsSaved: 'Asetukset tallennettu.',
|
settingsSaved: 'Asetukset tallennettu.',
|
||||||
emailLocked: 'Sähköpostia ei voi vaihtaa',
|
emailLocked: 'Sähköpostia ei voi vaihtaa',
|
||||||
save: 'Tallenna',
|
save: 'Tallenna',
|
||||||
|
|
@ -735,6 +773,25 @@ const svMessages: Record<keyof typeof baseMessages.en, string> = {
|
||||||
settingContactVisibilityTitle: 'Synlighet för kontaktuppgifter',
|
settingContactVisibilityTitle: 'Synlighet för kontaktuppgifter',
|
||||||
settingContactVisibilityHelp: 'Dölj värdens kontaktuppgifter för anonyma besökare så att bara inloggade ser dem.',
|
settingContactVisibilityHelp: 'Dölj värdens kontaktuppgifter för anonyma besökare så att bara inloggade ser dem.',
|
||||||
settingRequireLoginForContact: 'Kräv inloggning för att se kontaktuppgifter',
|
settingRequireLoginForContact: 'Kräv inloggning för att se kontaktuppgifter',
|
||||||
|
billingSettingsTitle: 'Faktureringsassistent',
|
||||||
|
billingSettingsLead: 'Aktivera n8n-fakturor om du vill och ange standard- samt objektspecifika uppgifter.',
|
||||||
|
billingEnableLabel: 'Aktivera faktureringsassistent för mina annonser',
|
||||||
|
billingAccountNameLabel: 'Kontoinnehavarens namn',
|
||||||
|
billingAccountPlaceholder: 'Använd profilens standard',
|
||||||
|
billingIbanLabel: 'IBAN för utbetalningar',
|
||||||
|
billingIbanPlaceholder: 'Använd profilens standard',
|
||||||
|
billingIncludeVat: 'Ta med momsrad på fakturor',
|
||||||
|
billingListingsTitle: 'Objektvisa inställningar',
|
||||||
|
billingListingsLead: 'Åsidosätt uppgifter per annons; tomma fält använder profilens värden.',
|
||||||
|
billingNoListings: 'Inga annonser ännu.',
|
||||||
|
billingVatChoice: 'Momsrad',
|
||||||
|
billingVatInherit: 'Använd profilinställning',
|
||||||
|
billingVatYes: 'Lägg till momsrad',
|
||||||
|
billingVatNo: 'Lägg inte till momsrad',
|
||||||
|
billingDisabledHint: 'Aktivera assistenten för att hantera fakturauppgifter och momsval.',
|
||||||
|
billingLoadFailed: 'Kunde inte ladda faktureringsinställningar.',
|
||||||
|
billingSaveFailed: 'Kunde inte spara faktureringsinställningar.',
|
||||||
|
billingSaved: 'Faktureringsinställningar sparade.',
|
||||||
settingsSaved: 'Inställningar sparade.',
|
settingsSaved: 'Inställningar sparade.',
|
||||||
translationMissing: 'Lägg till minst ett språk med titel och beskrivning.',
|
translationMissing: 'Lägg till minst ett språk med titel och beskrivning.',
|
||||||
saveDraft: 'Spara utkast',
|
saveDraft: 'Spara utkast',
|
||||||
|
|
|
||||||
11
prisma/migrations/20260311_billing_preferences/migration.sql
Normal file
11
prisma/migrations/20260311_billing_preferences/migration.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
-- Billing preferences for automated invoices + per-listing overrides
|
||||||
|
ALTER TABLE "User"
|
||||||
|
ADD COLUMN "billingEmailsEnabled" BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ADD COLUMN "billingAccountName" TEXT,
|
||||||
|
ADD COLUMN "billingIban" TEXT,
|
||||||
|
ADD COLUMN "billingIncludeVatLine" BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
ALTER TABLE "Listing"
|
||||||
|
ADD COLUMN "billingAccountName" TEXT,
|
||||||
|
ADD COLUMN "billingIban" TEXT,
|
||||||
|
ADD COLUMN "billingIncludeVatLine" BOOLEAN;
|
||||||
|
|
@ -49,6 +49,10 @@ model User {
|
||||||
verificationTokens VerificationToken[]
|
verificationTokens VerificationToken[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
billingEmailsEnabled Boolean @default(false)
|
||||||
|
billingAccountName String?
|
||||||
|
billingIban String?
|
||||||
|
billingIncludeVatLine Boolean @default(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
model Listing {
|
model Listing {
|
||||||
|
|
@ -95,6 +99,9 @@ model Listing {
|
||||||
calendarUrls String[] @db.Text @default([])
|
calendarUrls String[] @db.Text @default([])
|
||||||
priceWeekdayEuros Int?
|
priceWeekdayEuros Int?
|
||||||
priceWeekendEuros Int?
|
priceWeekendEuros Int?
|
||||||
|
billingAccountName String?
|
||||||
|
billingIban String?
|
||||||
|
billingIncludeVatLine Boolean?
|
||||||
contactName String?
|
contactName String?
|
||||||
contactEmail String?
|
contactEmail String?
|
||||||
contactPhone String?
|
contactPhone String?
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue