Hosting-Produkte: Staffelpreise nach Laufzeit (1/3/6/12/24 Monate)
This commit is contained in:
parent
e0fd57532d
commit
5efe37ecc0
12 changed files with 145 additions and 28 deletions
|
|
@ -15,11 +15,16 @@ import { canInOrg } from '../../core/policy.js';
|
||||||
import type { KcModule } from '../../core/module.js';
|
import type { KcModule } from '../../core/module.js';
|
||||||
|
|
||||||
const int = (max: number) => z.number().int().min(0).max(max);
|
const int = (max: number) => z.number().int().min(0).max(max);
|
||||||
|
const termPriceSchema = z.array(z.object({ termMonths: z.number().int().min(1).max(120), recurringCents: int(100_000_00) })).max(12)
|
||||||
|
.refine((l) => new Set(l.map((t) => t.termMonths)).size === l.length, 'Jede Laufzeit darf nur einmal vorkommen');
|
||||||
const versionSchema = z.object({
|
const versionSchema = z.object({
|
||||||
name: z.string().trim().min(1).max(200), description: z.string().trim().max(2000).optional(), taxRateId: z.string().uuid(), priceBasis: z.enum(['net', 'gross']).default('net'),
|
name: z.string().trim().min(1).max(200), description: z.string().trim().max(2000).optional(), taxRateId: z.string().uuid(), priceBasis: z.enum(['net', 'gross']).default('net'),
|
||||||
setupCents: int(100_000_00).default(0), recurringCents: int(100_000_00).default(0), billingInterval: z.enum(['once', 'monthly', 'yearly']),
|
setupCents: int(100_000_00).default(0), recurringCents: int(100_000_00).default(0), billingInterval: z.enum(['once', 'monthly', 'yearly']),
|
||||||
termMonths: int(120).default(0), renewal: z.enum(['auto', 'none']).default('none'), renewalTermMonths: int(120).optional(), noticeDays: int(365).default(30),
|
termMonths: int(120).default(0), renewal: z.enum(['auto', 'none']).default('none'), renewalTermMonths: int(120).optional(), noticeDays: int(365).default(30),
|
||||||
provisioning: z.record(z.string(), z.unknown()).default({}),
|
provisioning: z.record(z.string(), z.unknown()).default({}),
|
||||||
|
// Staffelpreise nach Laufzeit (optional): z. B. 1/3/6/12/24 Monate mit je eigenem Gesamtpreis für die Laufzeit.
|
||||||
|
// Wählt ein Kunde/Personal bei der Bestellung eine Laufzeit daraus, gilt deren Preis statt recurringCents/billingInterval.
|
||||||
|
termPrices: termPriceSchema.default([]),
|
||||||
});
|
});
|
||||||
type VersionIn = z.infer<typeof versionSchema>;
|
type VersionIn = z.infer<typeof versionSchema>;
|
||||||
import { CUSTOMER_ACTIONS } from '../../core/actions.js';
|
import { CUSTOMER_ACTIONS } from '../../core/actions.js';
|
||||||
|
|
@ -63,9 +68,11 @@ async function insertVersion(c: Parameters<typeof run>[2], productId: string, v:
|
||||||
`INSERT INTO product_versions (id, product_id, version, name, description, tax_rate_id, tax_bp, price_basis, setup_cents, recurring_cents, billing_interval, term_months, renewal, renewal_term_months, notice_days, provisioning_json, created_by)
|
`INSERT INTO product_versions (id, product_id, version, name, description, tax_rate_id, tax_bp, price_basis, setup_cents, recurring_cents, billing_interval, term_months, renewal, renewal_term_months, notice_days, provisioning_json, created_by)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
[id, productId, Number(last!.n) + 1, v.name, v.description ?? null, v.taxRateId, tax.rate_bp, v.priceBasis, v.setupCents, v.recurringCents, v.billingInterval, v.termMonths, v.renewal, renewalMonths, v.noticeDays, JSON.stringify(v.provisioning), by], c);
|
[id, productId, Number(last!.n) + 1, v.name, v.description ?? null, v.taxRateId, tax.rate_bp, v.priceBasis, v.setupCents, v.recurringCents, v.billingInterval, v.termMonths, v.renewal, renewalMonths, v.noticeDays, JSON.stringify(v.provisioning), by], c);
|
||||||
|
for (const t of v.termPrices) await run('INSERT INTO product_term_prices (id, product_version_id, term_months, recurring_cents) VALUES (?,?,?,?)', [randomUUID(), id, t.termMonths, t.recurringCents], c);
|
||||||
await run('UPDATE products SET current_version_id = ? WHERE id = ?', [id, productId], c);
|
await run('UPDATE products SET current_version_id = ? WHERE id = ?', [id, productId], c);
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
const termPricesFor = (versionId: string | null) => versionId ? query('SELECT term_months, recurring_cents FROM product_term_prices WHERE product_version_id = ? ORDER BY term_months', [versionId]) : Promise.resolve([]);
|
||||||
interface NewProduct { sku: string; category: string; connectorInstanceId?: string | null; externalRef?: string; orderableByCustomer: boolean; requiresApproval: boolean; customerActions: string[]; status: 'draft' | 'active'; version: VersionIn }
|
interface NewProduct { sku: string; category: string; connectorInstanceId?: string | null; externalRef?: string; orderableByCustomer: boolean; requiresApproval: boolean; customerActions: string[]; status: 'draft' | 'active'; version: VersionIn }
|
||||||
/** Legt ein Produkt mit erster Version an (Regeln, Verbindungsprüfung, Eindeutigkeit der Artikelnummer). */
|
/** Legt ein Produkt mit erster Version an (Regeln, Verbindungsprüfung, Eindeutigkeit der Artikelnummer). */
|
||||||
async function createProduct(b: NewProduct, actorId: string): Promise<string> {
|
async function createProduct(b: NewProduct, actorId: string): Promise<string> {
|
||||||
|
|
@ -124,7 +131,8 @@ export const catalogModule: KcModule = {
|
||||||
});
|
});
|
||||||
app.get('/admin/products', async (req) => {
|
app.get('/admin/products', async (req) => {
|
||||||
requirePermission(req, 'products.read');
|
requirePermission(req, 'products.read');
|
||||||
return (await query(`${productSelect} ORDER BY p.created_at DESC`)).map(productView);
|
const rows = await query(`${productSelect} ORDER BY p.created_at DESC`);
|
||||||
|
return Promise.all(rows.map(async (r) => ({ ...productView(r), termPrices: (await termPricesFor(r.vid ?? null)).map((t: any) => ({ termMonths: t.term_months, recurringCents: t.recurring_cents })) })));
|
||||||
});
|
});
|
||||||
app.get('/admin/products/:id', async (req) => {
|
app.get('/admin/products/:id', async (req) => {
|
||||||
requirePermission(req, 'products.read');
|
requirePermission(req, 'products.read');
|
||||||
|
|
@ -132,7 +140,8 @@ export const catalogModule: KcModule = {
|
||||||
const r = await one(`${productSelect} WHERE p.id = ?`, [id]);
|
const r = await one(`${productSelect} WHERE p.id = ?`, [id]);
|
||||||
if (!r) throw notFound();
|
if (!r) throw notFound();
|
||||||
const versions = await query('SELECT * FROM product_versions WHERE product_id = ? ORDER BY version DESC', [id]);
|
const versions = await query('SELECT * FROM product_versions WHERE product_id = ? ORDER BY version DESC', [id]);
|
||||||
return { ...productView(r), versions: versions.map(versionView) };
|
const termPrices = (await termPricesFor(r.vid ?? null)).map((t: any) => ({ termMonths: t.term_months, recurringCents: t.recurring_cents }));
|
||||||
|
return { ...productView(r), termPrices, versions: versions.map(versionView) };
|
||||||
});
|
});
|
||||||
app.post('/admin/products', async (req) => {
|
app.post('/admin/products', async (req) => {
|
||||||
const a = requirePermission(req, 'products.write');
|
const a = requirePermission(req, 'products.write');
|
||||||
|
|
@ -217,7 +226,7 @@ export const catalogModule: KcModule = {
|
||||||
const provisioning = inst.connector_key === 'licensing' ? { programId: Number(p.provisioning.programId ?? b.programRef), ...p.provisioning } : p.provisioning;
|
const provisioning = inst.connector_key === 'licensing' ? { programId: Number(p.provisioning.programId ?? b.programRef), ...p.provisioning } : p.provisioning;
|
||||||
if (inst.connector_key === 'licensing') provisioning.programId = Number(b.programRef);
|
if (inst.connector_key === 'licensing') provisioning.programId = Number(b.programRef);
|
||||||
const id = await createProduct({ sku: p.sku, category: p.category, connectorInstanceId: b.connectorInstanceId, externalRef: b.programRef, orderableByCustomer: p.orderableByCustomer, requiresApproval: p.requiresApproval, customerActions: p.customerActions, status: 'draft',
|
const id = await createProduct({ sku: p.sku, category: p.category, connectorInstanceId: b.connectorInstanceId, externalRef: b.programRef, orderableByCustomer: p.orderableByCustomer, requiresApproval: p.requiresApproval, customerActions: p.customerActions, status: 'draft',
|
||||||
version: { name: p.name, description: p.description, taxRateId: t.id, priceBasis: p.priceBasis, setupCents: p.setupCents, recurringCents: p.recurringCents, billingInterval: p.interval, termMonths: p.termMonths, renewal: p.renewal, renewalTermMonths: p.renewalTermMonths || undefined, noticeDays: p.noticeDays, provisioning } }, a.user.id);
|
version: { name: p.name, description: p.description, taxRateId: t.id, priceBasis: p.priceBasis, setupCents: p.setupCents, recurringCents: p.recurringCents, billingInterval: p.interval, termMonths: p.termMonths, renewal: p.renewal, renewalTermMonths: p.renewalTermMonths || undefined, noticeDays: p.noticeDays, provisioning, termPrices: [] } }, a.user.id);
|
||||||
created.push({ sku, id });
|
created.push({ sku, id });
|
||||||
} catch (e) { skipped.push({ sku, reason: e instanceof AppError ? e.message : 'Fehler beim Anlegen' }); }
|
} catch (e) { skipped.push({ sku, reason: e instanceof AppError ? e.message : 'Fehler beim Anlegen' }); }
|
||||||
}
|
}
|
||||||
|
|
@ -264,10 +273,15 @@ export const catalogModule: KcModule = {
|
||||||
if (!canInOrg(a.principal, q.org, 'orders.read', 'products.read')) throw notFound();
|
if (!canInOrg(a.principal, q.org, 'orders.read', 'products.read')) throw notFound();
|
||||||
const org = await one('SELECT customer_type FROM organizations WHERE id = ?', [q.org]);
|
const org = await one('SELECT customer_type FROM organizations WHERE id = ?', [q.org]);
|
||||||
const rows = await query(`${productSelect} WHERE p.status = 'active' AND p.orderable_by_customer = 1 ORDER BY v.name`);
|
const rows = await query(`${productSelect} WHERE p.status = 'active' AND p.orderable_by_customer = 1 ORDER BY v.name`);
|
||||||
return rows.map((r) => {
|
return Promise.all(rows.map(async (r) => {
|
||||||
const price = calculatePrice({ basis: r.price_basis, setupCents: r.setup_cents, recurringCents: r.recurring_cents, taxBp: r.tax_bp, interval: r.billing_interval, quantity: 1, discountBp: 0 });
|
const price = calculatePrice({ basis: r.price_basis, setupCents: r.setup_cents, recurringCents: r.recurring_cents, taxBp: r.tax_bp, interval: r.billing_interval, quantity: 1, discountBp: 0 });
|
||||||
return { id: r.id, sku: r.sku, category: r.category, name: r.vname, description: r.description, requiresApproval: !!r.requires_approval, customerType: org?.customer_type, price, termMonths: r.term_months, renewal: r.renewal, renewalTermMonths: r.renewal_term_months, noticeDays: r.notice_days };
|
// Staffelpreise nach Laufzeit (optional): je gewählter Laufzeit ein eigener Gesamtpreis statt des Basispreises.
|
||||||
});
|
const tiers = (await termPricesFor(r.vid ?? null)).map((t: any) => ({
|
||||||
|
termMonths: t.term_months,
|
||||||
|
price: calculatePrice({ basis: r.price_basis, setupCents: 0, recurringCents: t.recurring_cents, taxBp: r.tax_bp, interval: r.billing_interval, quantity: 1, discountBp: 0 }).recurring,
|
||||||
|
}));
|
||||||
|
return { id: r.id, sku: r.sku, category: r.category, name: r.vname, description: r.description, requiresApproval: !!r.requires_approval, customerType: org?.customer_type, price, termMonths: r.term_months, renewal: r.renewal, renewalTermMonths: r.renewal_term_months, noticeDays: r.notice_days, termPrices: tiers };
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -147,13 +147,20 @@ export const invoicesModule: KcModule = {
|
||||||
/** Vorschlag aus dem aktuellen Katalogpreis eines Produkts (Einrichtung und/oder wiederkehrender Preis als eigene Positionen). */
|
/** Vorschlag aus dem aktuellen Katalogpreis eines Produkts (Einrichtung und/oder wiederkehrender Preis als eigene Positionen). */
|
||||||
app.get('/invoices/suggest-from-product/:productId', async (req) => {
|
app.get('/invoices/suggest-from-product/:productId', async (req) => {
|
||||||
const a = requirePermission(req, 'invoices.write'); const { productId } = z.object({ productId: z.string().uuid() }).parse(req.params);
|
const a = requirePermission(req, 'invoices.write'); const { productId } = z.object({ productId: z.string().uuid() }).parse(req.params);
|
||||||
const p = await one('SELECT p.*, v.name, v.tax_bp, v.price_basis, v.setup_cents, v.recurring_cents, v.billing_interval FROM products p JOIN product_versions v ON v.id = p.current_version_id WHERE p.id = ?', [productId]);
|
const q = z.object({ termMonths: z.coerce.number().int().min(1).max(120).optional() }).parse(req.query);
|
||||||
|
const p = await one('SELECT p.*, v.id AS vid, v.name, v.tax_bp, v.price_basis, v.setup_cents, v.recurring_cents, v.billing_interval FROM products p JOIN product_versions v ON v.id = p.current_version_id WHERE p.id = ?', [productId]);
|
||||||
if (!p) throw notFound();
|
if (!p) throw notFound();
|
||||||
const price = calculatePrice({ basis: p.price_basis, setupCents: p.setup_cents, recurringCents: p.recurring_cents, taxBp: p.tax_bp, interval: p.billing_interval, quantity: 1, discountBp: 0 });
|
let recurringCents = p.recurring_cents; let label = p.name;
|
||||||
|
if (q.termMonths !== undefined) {
|
||||||
|
const tier = await one('SELECT recurring_cents FROM product_term_prices WHERE product_version_id = ? AND term_months = ?', [p.vid, q.termMonths]);
|
||||||
|
if (!tier) throw badRequest('Diese Laufzeit wird für dieses Produkt nicht angeboten', 'TERM_NOT_AVAILABLE');
|
||||||
|
recurringCents = tier.recurring_cents; label = `${p.name} (${q.termMonths} Monate)`;
|
||||||
|
}
|
||||||
|
const price = calculatePrice({ basis: p.price_basis, setupCents: p.setup_cents, recurringCents, taxBp: p.tax_bp, interval: p.billing_interval, quantity: 1, discountBp: 0 });
|
||||||
const items = [];
|
const items = [];
|
||||||
if (price.setup.net > 0) items.push({ description: `Einrichtung: ${p.name}`, quantity: 1, unitPriceNetCents: price.setup.net, taxBp: p.tax_bp });
|
if (price.setup.net > 0) items.push({ description: `Einrichtung: ${p.name}`, quantity: 1, unitPriceNetCents: price.setup.net, taxBp: p.tax_bp });
|
||||||
if (price.recurring.net > 0) items.push({ description: p.name, quantity: 1, unitPriceNetCents: price.recurring.net, taxBp: p.tax_bp });
|
if (price.recurring.net > 0) items.push({ description: label, quantity: 1, unitPriceNetCents: price.recurring.net, taxBp: p.tax_bp });
|
||||||
if (items.length === 0) items.push({ description: p.name, quantity: 1, unitPriceNetCents: 0, taxBp: p.tax_bp });
|
if (items.length === 0) items.push({ description: label, quantity: 1, unitPriceNetCents: 0, taxBp: p.tax_bp });
|
||||||
return { items };
|
return { items };
|
||||||
});
|
});
|
||||||
/** Vorschlag aus der Domain-Aufstellung: der dort errechnete Verkaufspreis (nie der Einkauf), plus Einrichtung falls vorhanden. */
|
/** Vorschlag aus der Domain-Aufstellung: der dort errechnete Verkaufspreis (nie der Einkauf), plus Einrichtung falls vorhanden. */
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ export const ordersModule: KcModule = {
|
||||||
// ---- Bestellung anlegen ------------------------------------------------
|
// ---- Bestellung anlegen ------------------------------------------------
|
||||||
app.post('/orders', async (req) => {
|
app.post('/orders', async (req) => {
|
||||||
const a = requireAuth(req);
|
const a = requireAuth(req);
|
||||||
const b = z.object({ orgId: z.string().uuid(), note: z.string().trim().max(500).optional(), items: z.array(z.object({ productId: z.string().uuid(), quantity: z.number().int().min(1).max(100).default(1), discountBp: z.number().int().min(0).max(10000).default(0) })).min(1).max(20) }).parse(req.body);
|
const b = z.object({ orgId: z.string().uuid(), note: z.string().trim().max(500).optional(), items: z.array(z.object({ productId: z.string().uuid(), quantity: z.number().int().min(1).max(100).default(1), discountBp: z.number().int().min(0).max(10000).default(0), termMonths: z.number().int().min(1).max(120).optional() })).min(1).max(20) }).parse(req.body);
|
||||||
const staff = can(a.principal, 'orders.write');
|
const staff = can(a.principal, 'orders.write');
|
||||||
if (!canInOrg(a.principal, b.orgId, 'orders.create', 'orders.write')) { if (!canInOrg(a.principal, b.orgId, 'orders.read', 'orders.read')) throw notFound(); throw forbidden(); }
|
if (!canInOrg(a.principal, b.orgId, 'orders.create', 'orders.write')) { if (!canInOrg(a.principal, b.orgId, 'orders.read', 'orders.read')) throw notFound(); throw forbidden(); }
|
||||||
const org = await one('SELECT id, status, customer_type FROM organizations WHERE id = ?', [b.orgId]);
|
const org = await one('SELECT id, status, customer_type FROM organizations WHERE id = ?', [b.orgId]);
|
||||||
|
|
@ -74,9 +74,17 @@ export const ordersModule: KcModule = {
|
||||||
if (!staff && !p.orderable_by_customer) throw badRequest('Dieses Produkt kann nicht selbst bestellt werden', 'NOT_ORDERABLE');
|
if (!staff && !p.orderable_by_customer) throw badRequest('Dieses Produkt kann nicht selbst bestellt werden', 'NOT_ORDERABLE');
|
||||||
if (!staff && it.discountBp > 0) throw forbidden('Rabatte können nur vom Personal gewährt werden', 'DISCOUNT_FORBIDDEN');
|
if (!staff && it.discountBp > 0) throw forbidden('Rabatte können nur vom Personal gewährt werden', 'DISCOUNT_FORBIDDEN');
|
||||||
if (p.connector_instance_id && it.quantity !== 1) throw badRequest('Provisionierte Produkte können nur einzeln bestellt werden', 'QUANTITY_NOT_ALLOWED');
|
if (p.connector_instance_id && it.quantity !== 1) throw badRequest('Provisionierte Produkte können nur einzeln bestellt werden', 'QUANTITY_NOT_ALLOWED');
|
||||||
// Preis IMMER serverseitig aus der aktuellen Produktversion; Eingaben des Clients beeinflussen den Preis nicht
|
// Staffelpreis nach Laufzeit (optional): ersetzt den wiederkehrenden Basispreis, wenn eine passende Laufzeit gewählt wurde
|
||||||
const price = calculatePrice({ basis: p.price_basis, setupCents: p.setup_cents, recurringCents: p.recurring_cents, taxBp: p.tax_bp, interval: p.billing_interval, quantity: it.quantity, discountBp: it.discountBp });
|
// (das Abrechnungsintervall bleibt für die Anzeige erhalten, z. B. "jährlich"; die tatsächliche Laufzeit steht separat in terms.termMonths).
|
||||||
let terms = { termMonths: p.term_months as number, renewal: p.renewal as 'auto' | 'none', renewalTermMonths: p.renewal_term_months as number, noticeDays: p.notice_days as number };
|
let recurringCents = p.recurring_cents, termMonths = p.term_months as number, renewalTermMonths = p.renewal_term_months as number;
|
||||||
|
if (it.termMonths !== undefined) {
|
||||||
|
const tier = await one('SELECT recurring_cents FROM product_term_prices WHERE product_version_id = ? AND term_months = ?', [p.vid, it.termMonths]);
|
||||||
|
if (!tier) throw badRequest('Diese Laufzeit wird für dieses Produkt nicht angeboten', 'TERM_NOT_AVAILABLE');
|
||||||
|
recurringCents = tier.recurring_cents; termMonths = it.termMonths; if (p.renewal === 'auto') renewalTermMonths = it.termMonths;
|
||||||
|
}
|
||||||
|
// Preis IMMER serverseitig aus der aktuellen Produktversion; Eingaben des Clients beeinflussen den Preis nicht (außer der Laufzeitwahl oben)
|
||||||
|
const price = calculatePrice({ basis: p.price_basis, setupCents: p.setup_cents, recurringCents, taxBp: p.tax_bp, interval: p.billing_interval, quantity: it.quantity, discountBp: it.discountBp });
|
||||||
|
let terms = { termMonths, renewal: p.renewal as 'auto' | 'none', renewalTermMonths, noticeDays: p.notice_days as number };
|
||||||
if (org.customer_type === 'private' && terms.renewal === 'auto') terms = { ...terms, ...consumerTerms(terms.renewalTermMonths, terms.noticeDays) }; // Verbraucherregel, rechtlich zu prüfen
|
if (org.customer_type === 'private' && terms.renewal === 'auto') terms = { ...terms, ...consumerTerms(terms.renewalTermMonths, terms.noticeDays) }; // Verbraucherregel, rechtlich zu prüfen
|
||||||
lines.push({ p, it, snapshot: { productId: p.id, sku: p.sku, productVersionId: p.vid, version: p.version, name: p.vname, category: p.category, customerType: org.customer_type, ...price, terms } });
|
lines.push({ p, it, snapshot: { productId: p.id, sku: p.sku, productVersionId: p.vid, version: p.version, name: p.vname, category: p.category, customerType: org.customer_type, ...price, terms } });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -113,3 +113,20 @@ describe('Rechnungspositionen aus der Domain-Aufstellung', () => {
|
||||||
expect((await call(app, acc, 'GET', `/invoices/${draft.id}`)).json().totalNetCents).toBe(1891);
|
expect((await call(app, acc, 'GET', `/invoices/${draft.id}`)).json().totalNetCents).toBe(1891);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Rechnungsposition aus Produkt mit Laufzeit-Staffel', () => {
|
||||||
|
it('Ohne Laufzeitangabe der Basispreis, mit Laufzeitangabe die Staffel; unbekannte Laufzeit abgelehnt', async () => {
|
||||||
|
const admin = await staff('psugg-admin@example.com', 'superadmin'); const acc = await staff('psugg-acc@example.com', 'accounting');
|
||||||
|
const t19 = (await call(app, admin, 'GET', '/admin/tax-rates')).json().find((t: any) => t.rateBp === 1900).id;
|
||||||
|
const prod = await call(app, admin, 'POST', '/admin/products', {
|
||||||
|
sku: 'HOST-PSUGG', category: 'hosting', orderableByCustomer: true, requiresApproval: false, customerActions: [], status: 'active',
|
||||||
|
version: { name: 'Hosting X', taxRateId: t19, setupCents: 0, recurringCents: 999, billingInterval: 'monthly', termMonths: 1, renewal: 'auto', renewalTermMonths: 1, noticeDays: 30, provisioning: {},
|
||||||
|
termPrices: [{ termMonths: 12, recurringCents: 9588 }] },
|
||||||
|
});
|
||||||
|
const base = (await call(app, acc, 'GET', `/invoices/suggest-from-product/${prod.json().id}`)).json();
|
||||||
|
expect(base.items).toEqual([{ description: 'Hosting X', quantity: 1, unitPriceNetCents: 999, taxBp: 1900 }]);
|
||||||
|
const tier = (await call(app, acc, 'GET', `/invoices/suggest-from-product/${prod.json().id}?termMonths=12`)).json();
|
||||||
|
expect(tier.items).toEqual([{ description: 'Hosting X (12 Monate)', quantity: 1, unitPriceNetCents: 9588, taxBp: 1900 }]);
|
||||||
|
expect((await call(app, acc, 'GET', `/invoices/suggest-from-product/${prod.json().id}?termMonths=6`)).statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -266,3 +266,37 @@ describe('Familytool-Editionen (Paket), Edition und Laufzeitpflege', () => {
|
||||||
const days = (new Date(tr.validUntil).getTime() - Date.now()) / 86400000; expect(days).toBeGreaterThan(13); expect(days).toBeLessThan(14.1);
|
const days = (new Date(tr.validUntil).getTime() - Date.now()) / 86400000; expect(days).toBeGreaterThan(13); expect(days).toBeLessThan(14.1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Staffelpreise nach Laufzeit', () => {
|
||||||
|
it('Produkt mit mehreren Laufzeiten: Bestellung wählt eine Staffel, Preis/Laufzeit werden serverseitig aus der Staffel übernommen', async () => {
|
||||||
|
const admin = await staff('term-admin@example.com', 'admin');
|
||||||
|
const A = await customer(admin, 'business', 'Firma Term', 'term-a@example.com');
|
||||||
|
const t19 = (await call(app, admin, 'GET', '/admin/tax-rates')).json().find((t: any) => t.rateBp === 1900).id;
|
||||||
|
const prod = await call(app, admin, 'POST', '/admin/products', {
|
||||||
|
sku: 'HOST-TERM', category: 'hosting', orderableByCustomer: true, requiresApproval: false, customerActions: [], status: 'active',
|
||||||
|
version: { name: 'Hosting Staffel', taxRateId: t19, setupCents: 500, recurringCents: 999, billingInterval: 'monthly', termMonths: 1, renewal: 'auto', renewalTermMonths: 1, noticeDays: 30, provisioning: {},
|
||||||
|
termPrices: [{ termMonths: 1, recurringCents: 999 }, { termMonths: 3, recurringCents: 2699 }, { termMonths: 12, recurringCents: 9588 }, { termMonths: 24, recurringCents: 17988 }] },
|
||||||
|
});
|
||||||
|
expect(prod.statusCode).toBe(200);
|
||||||
|
// doppelte Laufzeit wird abgelehnt
|
||||||
|
expect((await call(app, admin, 'POST', '/admin/products', {
|
||||||
|
sku: 'HOST-TERM-BAD', category: 'hosting', orderableByCustomer: true, requiresApproval: false, customerActions: [], status: 'draft',
|
||||||
|
version: { name: 'x', taxRateId: t19, setupCents: 0, recurringCents: 0, billingInterval: 'monthly', termMonths: 1, renewal: 'none', noticeDays: 30, provisioning: {}, termPrices: [{ termMonths: 12, recurringCents: 100 }, { termMonths: 12, recurringCents: 200 }] },
|
||||||
|
})).statusCode).toBe(400);
|
||||||
|
|
||||||
|
const cat = (await call(app, A.client, 'GET', `/catalog?org=${A.org}`)).json();
|
||||||
|
const item = cat.find((c: any) => c.sku === 'HOST-TERM');
|
||||||
|
expect(item.termPrices).toEqual([
|
||||||
|
{ termMonths: 1, price: { net: 999, tax: 190, gross: 1189 } }, { termMonths: 3, price: { net: 2699, tax: 513, gross: 3212 } },
|
||||||
|
{ termMonths: 12, price: { net: 9588, tax: 1822, gross: 11410 } }, { termMonths: 24, price: { net: 17988, tax: 3418, gross: 21406 } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// nicht angebotene Laufzeit wird abgelehnt
|
||||||
|
expect((await call(app, A.client, 'POST', '/orders', { orgId: A.org, items: [{ productId: prod.json().id, termMonths: 6 }] })).statusCode).toBe(400);
|
||||||
|
|
||||||
|
const order = (await call(app, A.client, 'POST', '/orders', { orgId: A.org, items: [{ productId: prod.json().id, termMonths: 12 }] })).json();
|
||||||
|
const items = (await call(app, admin, 'GET', `/orders/${order.id}`)).json().items;
|
||||||
|
expect(items[0].snapshot.recurring).toEqual({ net: 9588, tax: 1822, gross: 11410 });
|
||||||
|
expect(items[0].snapshot.terms).toEqual({ termMonths: 12, renewal: 'auto', renewalTermMonths: 12, noticeDays: 30 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import mysql from 'mysql2/promise';
|
||||||
import '../src/core/config.js';
|
import '../src/core/config.js';
|
||||||
|
|
||||||
/** Vor jeder Testdatei: Datenzeilen leeren (Testdatenbank!), Stammdaten (Steuersätze, Einstellungen) und Zähler zurücksetzen. */
|
/** Vor jeder Testdatei: Datenzeilen leeren (Testdatenbank!), Stammdaten (Steuersätze, Einstellungen) und Zähler zurücksetzen. */
|
||||||
const DATA = ['backup_targets', 'backup_settings', 'domain_tlds', 'domain_records', 'ticket_attachments', 'ticket_messages', 'tickets', 'invoice_items', 'invoices', 'audit_events', 'jobs', 'mail_log', 'contracts', 'order_items', 'orders', 'product_versions', 'products', 'resources', 'connector_instances', 'sessions', 'mfa_totp', 'recovery_codes', 'user_tokens', 'memberships', 'billing_profiles', 'organizations', 'users'];
|
const DATA = ['backup_targets', 'backup_settings', 'domain_tlds', 'domain_records', 'ticket_attachments', 'ticket_messages', 'tickets', 'invoice_items', 'invoices', 'product_term_prices', 'audit_events', 'jobs', 'mail_log', 'contracts', 'order_items', 'orders', 'product_versions', 'products', 'resources', 'connector_instances', 'sessions', 'mfa_totp', 'recovery_codes', 'user_tokens', 'memberships', 'billing_profiles', 'organizations', 'users'];
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
if (process.env.DB_NAME !== 'kundencenter_test') throw new Error('Tests dürfen nur gegen kundencenter_test laufen');
|
if (process.env.DB_NAME !== 'kundencenter_test') throw new Error('Tests dürfen nur gegen kundencenter_test laufen');
|
||||||
const c = await mysql.createConnection({ host: process.env.DB_HOST ?? '127.0.0.1', user: process.env.DB_USER!, password: process.env.DB_PASSWORD!, database: 'kundencenter_test' });
|
const c = await mysql.createConnection({ host: process.env.DB_HOST ?? '127.0.0.1', user: process.env.DB_USER!, password: process.env.DB_PASSWORD!, database: 'kundencenter_test' });
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { useSession } from '@/lib/session';
|
||||||
import { Alert, eur, Field, fmt, INTERVAL, Status, CUSTOMER_ACTIONS, ACTION_LABEL } from '@/components/ui';
|
import { Alert, eur, Field, fmt, INTERVAL, Status, CUSTOMER_ACTIONS, ACTION_LABEL } from '@/components/ui';
|
||||||
import type { Prod } from '../page';
|
import type { Prod } from '../page';
|
||||||
|
|
||||||
|
const TERMS = [1, 3, 6, 12, 24];
|
||||||
type D = Prod & { versions: NonNullable<Prod['current']>[] };
|
type D = Prod & { versions: NonNullable<Prod['current']>[] };
|
||||||
export default function Produkt() {
|
export default function Produkt() {
|
||||||
const { id } = useParams<{ id: string }>(); const { can } = useSession(); const w = can('products.write');
|
const { id } = useParams<{ id: string }>(); const { can } = useSession(); const w = can('products.write');
|
||||||
|
|
@ -18,8 +19,9 @@ export default function Produkt() {
|
||||||
async function patch(b: object, ok: string) { try { await api('PATCH', `/admin/products/${id}`, b); setMsg({ k: 'ok', t: ok }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } }
|
async function patch(b: object, ok: string) { try { await api('PATCH', `/admin/products/${id}`, b); setMsg({ k: 'ok', t: ok }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } }
|
||||||
async function newVersion(e: FormEvent<HTMLFormElement>) {
|
async function newVersion(e: FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault(); const f = new FormData(e.currentTarget); const once = cur.billingInterval === 'once';
|
e.preventDefault(); const f = new FormData(e.currentTarget); const once = cur.billingInterval === 'once';
|
||||||
|
const termPrices = once ? [] : TERMS.map((m) => ({ termMonths: m, recurringCents: f.get(`term${m}`) === '' || f.get(`term${m}`) === null ? null : Math.round(Number(f.get(`term${m}`)) * 100) })).filter((t): t is { termMonths: number; recurringCents: number } => t.recurringCents !== null);
|
||||||
try { await api('POST', `/admin/products/${id}/versions`, { name: f.get('name'), taxRateId: f.get('tax'), priceBasis: cur.priceBasis, setupCents: Math.round(Number(f.get('setup') || 0) * 100), recurringCents: once ? 0 : Math.round(Number(f.get('recurring') || 0) * 100), billingInterval: cur.billingInterval,
|
try { await api('POST', `/admin/products/${id}/versions`, { name: f.get('name'), taxRateId: f.get('tax'), priceBasis: cur.priceBasis, setupCents: Math.round(Number(f.get('setup') || 0) * 100), recurringCents: once ? 0 : Math.round(Number(f.get('recurring') || 0) * 100), billingInterval: cur.billingInterval,
|
||||||
termMonths: cur.termMonths, renewal: cur.renewal, renewalTermMonths: cur.renewalTermMonths || undefined, noticeDays: Number(f.get('notice') || cur.noticeDays), provisioning: d!.provisioning });
|
termMonths: cur.termMonths, renewal: cur.renewal, renewalTermMonths: cur.renewalTermMonths || undefined, noticeDays: Number(f.get('notice') || cur.noticeDays), provisioning: d!.provisioning, termPrices });
|
||||||
setOpen(false); setMsg({ k: 'ok', t: 'Neue Version angelegt. Bestehende Verträge behalten ihren Preis.' }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
|
setOpen(false); setMsg({ k: 'ok', t: 'Neue Version angelegt. Bestehende Verträge behalten ihren Preis.' }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); }
|
||||||
}
|
}
|
||||||
return (<>
|
return (<>
|
||||||
|
|
@ -29,6 +31,7 @@ export default function Produkt() {
|
||||||
<div className="card"><h2>Aktuelle Konditionen (Version {cur.version})</h2>
|
<div className="card"><h2>Aktuelle Konditionen (Version {cur.version})</h2>
|
||||||
<p>{cur.setupCents > 0 && <>Einrichtung {eur(cur.setupCents)} {cur.priceBasis === 'gross' ? 'brutto' : 'netto'}<br /></>}{cur.billingInterval === 'once' ? 'Einmalpreis' : `${INTERVAL[cur.billingInterval]} ${eur(cur.recurringCents)} ${cur.priceBasis === 'gross' ? 'brutto' : 'netto'}`} · Steuer {cur.taxBp / 100} %</p>
|
<p>{cur.setupCents > 0 && <>Einrichtung {eur(cur.setupCents)} {cur.priceBasis === 'gross' ? 'brutto' : 'netto'}<br /></>}{cur.billingInterval === 'once' ? 'Einmalpreis' : `${INTERVAL[cur.billingInterval]} ${eur(cur.recurringCents)} ${cur.priceBasis === 'gross' ? 'brutto' : 'netto'}`} · Steuer {cur.taxBp / 100} %</p>
|
||||||
<p className="small muted">{cur.termMonths > 0 ? `Mindestlaufzeit ${cur.termMonths} Monate` : 'Keine Mindestlaufzeit'}{cur.renewal === 'auto' ? `, Verlängerung um ${cur.renewalTermMonths} Monat(e)` : ''}, Kündigungsfrist {cur.noticeDays} Tage · Bereitstellung: {d.connectorName ?? 'manuell'}{Object.keys(d.provisioning).length ? ` (${JSON.stringify(d.provisioning)})` : ''}</p>
|
<p className="small muted">{cur.termMonths > 0 ? `Mindestlaufzeit ${cur.termMonths} Monate` : 'Keine Mindestlaufzeit'}{cur.renewal === 'auto' ? `, Verlängerung um ${cur.renewalTermMonths} Monat(e)` : ''}, Kündigungsfrist {cur.noticeDays} Tage · Bereitstellung: {d.connectorName ?? 'manuell'}{Object.keys(d.provisioning).length ? ` (${JSON.stringify(d.provisioning)})` : ''}</p>
|
||||||
|
{d.termPrices.length > 0 && <p className="small muted">Laufzeit-Staffelpreise: {d.termPrices.map((t) => `${t.termMonths} Mon. ${eur(t.recurringCents)}`).join(' · ')}</p>}
|
||||||
{w && <div className="row">{d.status !== 'active' && <button className="btn primary" onClick={() => patch({ status: 'active' }, 'Produkt aktiviert.')}>Aktivieren</button>}
|
{w && <div className="row">{d.status !== 'active' && <button className="btn primary" onClick={() => patch({ status: 'active' }, 'Produkt aktiviert.')}>Aktivieren</button>}
|
||||||
{d.status === 'active' && <button className="btn" onClick={() => patch({ status: 'retired' }, 'Produkt ausgelaufen. Es kann nicht mehr bestellt werden.')}>Auslaufen lassen</button>}
|
{d.status === 'active' && <button className="btn" onClick={() => patch({ status: 'retired' }, 'Produkt ausgelaufen. Es kann nicht mehr bestellt werden.')}>Auslaufen lassen</button>}
|
||||||
<button className="btn" onClick={() => setOpen(!open)} aria-expanded={open}>Neue Version (Preis ändern)</button></div>}</div>
|
<button className="btn" onClick={() => setOpen(!open)} aria-expanded={open}>Neue Version (Preis ändern)</button></div>}</div>
|
||||||
|
|
@ -38,6 +41,11 @@ export default function Produkt() {
|
||||||
{cur.billingInterval !== 'once' && <Field id="recurring" label={`Preis (€ ${cur.priceBasis === 'gross' ? 'brutto' : 'netto'})`}><input id="recurring" name="recurring" type="number" min={0} step="0.01" defaultValue={cur.recurringCents / 100} /></Field>}
|
{cur.billingInterval !== 'once' && <Field id="recurring" label={`Preis (€ ${cur.priceBasis === 'gross' ? 'brutto' : 'netto'})`}><input id="recurring" name="recurring" type="number" min={0} step="0.01" defaultValue={cur.recurringCents / 100} /></Field>}
|
||||||
<Field id="tax" label="Steuersatz"><select id="tax" name="tax" defaultValue={tax.find((t) => t.rateBp === cur.taxBp)?.id}>{tax.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}</select></Field>
|
<Field id="tax" label="Steuersatz"><select id="tax" name="tax" defaultValue={tax.find((t) => t.rateBp === cur.taxBp)?.id}>{tax.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}</select></Field>
|
||||||
<Field id="notice" label="Kündigungsfrist (Tage)"><input id="notice" name="notice" type="number" min={0} defaultValue={cur.noticeDays} /></Field></div>
|
<Field id="notice" label="Kündigungsfrist (Tage)"><input id="notice" name="notice" type="number" min={0} defaultValue={cur.noticeDays} /></Field></div>
|
||||||
|
{cur.billingInterval !== 'once' && <>
|
||||||
|
<h3>Staffelpreise nach Laufzeit (optional)</h3>
|
||||||
|
<p className="small muted">Preis jeweils für die gesamte Laufzeit, nicht pro Monat. Leer lassen, wenn diese Laufzeit nicht angeboten wird.</p>
|
||||||
|
<div className="cols">{TERMS.map((m) => <Field key={m} id={`term${m}`} label={`${m} Monat${m > 1 ? 'e' : ''} (€ ${cur.priceBasis === 'gross' ? 'brutto' : 'netto'}, gesamt)`}><input id={`term${m}`} name={`term${m}`} type="number" min={0} step="0.01" defaultValue={d.termPrices.find((t) => t.termMonths === m) ? d.termPrices.find((t) => t.termMonths === m)!.recurringCents / 100 : ''} /></Field>)}</div>
|
||||||
|
</>}
|
||||||
<div className="row"><button className="btn primary" type="submit">Version speichern</button><button className="btn" type="button" onClick={() => setOpen(false)}>Abbrechen</button></div></form>}
|
<div className="row"><button className="btn primary" type="submit">Version speichern</button><button className="btn" type="button" onClick={() => setOpen(false)}>Abbrechen</button></div></form>}
|
||||||
<div className="card"><h2>Verkaufsregeln</h2><div className="row">
|
<div className="card"><h2>Verkaufsregeln</h2><div className="row">
|
||||||
<label className="row"><input type="checkbox" style={{ width: 20, minHeight: 20 }} disabled={!w} checked={d.orderableByCustomer} onChange={(e) => patch({ orderableByCustomer: e.target.checked }, 'Gespeichert.')} />Von Kunden selbst bestellbar</label>
|
<label className="row"><input type="checkbox" style={{ width: 20, minHeight: 20 }} disabled={!w} checked={d.orderableByCustomer} onChange={(e) => patch({ orderableByCustomer: e.target.checked }, 'Gespeichert.')} />Von Kunden selbst bestellbar</label>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { Alert, Field, ACTION_LABEL, CUSTOMER_ACTIONS, INTERVAL } from '@/compon
|
||||||
interface Conn { id: string; name: string; connector: string; capabilities: string[] }
|
interface Conn { id: string; name: string; connector: string; capabilities: string[] }
|
||||||
const LIMITS: [string, string, string][] = [['diskSpaceGb', 'Speicher (GB)', '10'], ['trafficGb', 'Traffic pro Monat (GB)', '100'], ['domains', 'Domains', '3'], ['subdomains', 'Subdomains', '10'], ['emailAccounts', 'Postfächer', '10'], ['emailAddresses', 'E-Mail-Adressen', '20'], ['emailForwardings', 'Weiterleitungen', '10'], ['databases', 'Datenbanken', '3'], ['ftpUsers', 'FTP-Benutzer', '3'], ['scheduledTasks', 'Cronjobs', '2']];
|
const LIMITS: [string, string, string][] = [['diskSpaceGb', 'Speicher (GB)', '10'], ['trafficGb', 'Traffic pro Monat (GB)', '100'], ['domains', 'Domains', '3'], ['subdomains', 'Subdomains', '10'], ['emailAccounts', 'Postfächer', '10'], ['emailAddresses', 'E-Mail-Adressen', '20'], ['emailForwardings', 'Weiterleitungen', '10'], ['databases', 'Datenbanken', '3'], ['ftpUsers', 'FTP-Benutzer', '3'], ['scheduledTasks', 'Cronjobs', '2']];
|
||||||
const PERMS: [string, string][] = [['panel_access', 'Zugang zum Kundenpanel'], ['ftp', 'FTP'], ['php', 'PHP'], ['ssh', 'SSH'], ['backup', 'Backups'], ['file_manager', 'Dateimanager'], ['dns_editor', 'DNS-Editor'], ['certificate_management', 'SSL-Zertifikate verwalten'], ['domain_email', 'E-Mail-Domains'], ['email_catchall', 'Catch-all'], ['database_remote_access', 'Datenbank-Fernzugriff'], ['delete_main_domain', 'Hauptdomain löschen']];
|
const PERMS: [string, string][] = [['panel_access', 'Zugang zum Kundenpanel'], ['ftp', 'FTP'], ['php', 'PHP'], ['ssh', 'SSH'], ['backup', 'Backups'], ['file_manager', 'Dateimanager'], ['dns_editor', 'DNS-Editor'], ['certificate_management', 'SSL-Zertifikate verwalten'], ['domain_email', 'E-Mail-Domains'], ['email_catchall', 'Catch-all'], ['database_remote_access', 'Datenbank-Fernzugriff'], ['delete_main_domain', 'Hauptdomain löschen']];
|
||||||
|
const TERMS = [1, 3, 6, 12, 24];
|
||||||
|
|
||||||
export default function HostingTarif() {
|
export default function HostingTarif() {
|
||||||
const { can } = useSession();
|
const { can } = useSession();
|
||||||
|
|
@ -21,11 +22,12 @@ export default function HostingTarif() {
|
||||||
const limits = Object.fromEntries(LIMITS.map(([k]) => [k, unl[k] ? null : (g(k) === '' ? undefined : Number(g(k)))]).filter(([, v]) => v !== undefined));
|
const limits = Object.fromEntries(LIMITS.map(([k]) => [k, unl[k] ? null : (g(k) === '' ? undefined : Number(g(k)))]).filter(([, v]) => v !== undefined));
|
||||||
const permissions = Object.fromEntries(PERMS.map(([k]) => [k, f.get(`perm-${k}`) === 'on']));
|
const permissions = Object.fromEntries(PERMS.map(([k]) => [k, f.get(`perm-${k}`) === 'on']));
|
||||||
const once = interval === 'once';
|
const once = interval === 'once';
|
||||||
|
const termPrices = once ? [] : TERMS.map((m) => ({ termMonths: m, recurringCents: g(`term${m}`) === '' ? null : Math.round(Number(g(`term${m}`)) * 100) })).filter((t): t is { termMonths: number; recurringCents: number } => t.recurringCents !== null);
|
||||||
try {
|
try {
|
||||||
const r = await api<{ productId: string; plan: { name: string; features: string[] } }>('POST', `/admin/connectors/${connId}/hosting-plans`, {
|
const r = await api<{ productId: string; plan: { name: string; features: string[] } }>('POST', `/admin/connectors/${connId}/hosting-plans`, {
|
||||||
plan: { name: g('planName'), limits, permissions },
|
plan: { name: g('planName'), limits, permissions },
|
||||||
product: { sku: g('sku'), status: f.get('activate') === 'on' ? 'active' : 'draft', orderableByCustomer: f.get('orderable') === 'on', requiresApproval: f.get('approval') === 'on', customerActions: CUSTOMER_ACTIONS.filter((a) => f.get(`act-${a}`) === 'on'),
|
product: { sku: g('sku'), status: f.get('activate') === 'on' ? 'active' : 'draft', orderableByCustomer: f.get('orderable') === 'on', requiresApproval: f.get('approval') === 'on', customerActions: CUSTOMER_ACTIONS.filter((a) => f.get(`act-${a}`) === 'on'),
|
||||||
version: { name: g('name') || g('planName'), description: g('desc') || undefined, taxRateId: g('tax'), priceBasis: basis, setupCents: Math.round(Number(g('setup') || 0) * 100), recurringCents: once ? 0 : Math.round(Number(g('recurring') || 0) * 100), billingInterval: interval, termMonths: once ? 0 : Number(g('term') || 0), renewal: !once && f.get('renewal') === 'on' ? 'auto' : 'none', noticeDays: Number(g('notice') || 30) } } });
|
version: { name: g('name') || g('planName'), description: g('desc') || undefined, taxRateId: g('tax'), priceBasis: basis, setupCents: Math.round(Number(g('setup') || 0) * 100), recurringCents: once ? 0 : Math.round(Number(g('recurring') || 0) * 100), billingInterval: interval, termMonths: once ? 0 : Number(g('term') || 0), renewal: !once && f.get('renewal') === 'on' ? 'auto' : 'none', noticeDays: Number(g('notice') || 30), termPrices } } });
|
||||||
setOk({ id: r.productId, name: r.plan.name, features: r.plan.features });
|
setOk({ id: r.productId, name: r.plan.name, features: r.plan.features });
|
||||||
} catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
|
} catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
|
||||||
}
|
}
|
||||||
|
|
@ -54,6 +56,11 @@ export default function HostingTarif() {
|
||||||
<Field id="tax" label="Steuersatz"><select id="tax" name="tax" defaultValue={tax.find((t) => t.rateBp === 1900)?.id}>{tax.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}</select></Field>
|
<Field id="tax" label="Steuersatz"><select id="tax" name="tax" defaultValue={tax.find((t) => t.rateBp === 1900)?.id}>{tax.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}</select></Field>
|
||||||
{interval !== 'once' && <><Field id="term" label="Mindestlaufzeit (Monate)"><input id="term" name="term" type="number" min={0} defaultValue={0} /></Field><Field id="notice" label="Kündigungsfrist (Tage)"><input id="notice" name="notice" type="number" min={0} defaultValue={30} /></Field></>}</div>
|
{interval !== 'once' && <><Field id="term" label="Mindestlaufzeit (Monate)"><input id="term" name="term" type="number" min={0} defaultValue={0} /></Field><Field id="notice" label="Kündigungsfrist (Tage)"><input id="notice" name="notice" type="number" min={0} defaultValue={30} /></Field></>}</div>
|
||||||
{interval !== 'once' && <label className="row"><input type="checkbox" name="renewal" defaultChecked style={{ width: 20, minHeight: 20 }} />Automatische Verlängerung</label>}
|
{interval !== 'once' && <label className="row"><input type="checkbox" name="renewal" defaultChecked style={{ width: 20, minHeight: 20 }} />Automatische Verlängerung</label>}
|
||||||
|
{interval !== 'once' && <>
|
||||||
|
<h3>Staffelpreise nach Laufzeit (optional)</h3>
|
||||||
|
<p className="small muted">Lässt Kunden bei der Bestellung eine Laufzeit wählen, z. B. günstiger bei 12 oder 24 Monaten. Preis jeweils für die gesamte Laufzeit, nicht pro Monat. Leer lassen, wenn diese Laufzeit nicht angeboten wird.</p>
|
||||||
|
<div className="cols">{TERMS.map((m) => <Field key={m} id={`term${m}`} label={`${m} Monat${m > 1 ? 'e' : ''} (€ ${basis === 'gross' ? 'brutto' : 'netto'}, gesamt)`}><input id={`term${m}`} name={`term${m}`} type="number" min={0} step="0.01" /></Field>)}</div>
|
||||||
|
</>}
|
||||||
<h3>Verkauf</h3>
|
<h3>Verkauf</h3>
|
||||||
<div className="row"><label className="row"><input type="checkbox" name="orderable" style={{ width: 20, minHeight: 20 }} />Von Kunden selbst bestellbar</label><label className="row"><input type="checkbox" name="approval" defaultChecked style={{ width: 20, minHeight: 20 }} />Manuelle Freigabe erforderlich</label><label className="row"><input type="checkbox" name="activate" style={{ width: 20, minHeight: 20 }} />Sofort aktivieren</label></div>
|
<div className="row"><label className="row"><input type="checkbox" name="orderable" style={{ width: 20, minHeight: 20 }} />Von Kunden selbst bestellbar</label><label className="row"><input type="checkbox" name="approval" defaultChecked style={{ width: 20, minHeight: 20 }} />Manuelle Freigabe erforderlich</label><label className="row"><input type="checkbox" name="activate" style={{ width: 20, minHeight: 20 }} />Sofort aktivieren</label></div>
|
||||||
<fieldset style={{ border: 0, padding: 0, margin: '12px 0' }}><legend className="small" style={{ fontWeight: 600, marginBottom: 8 }}>Was Kunden am Hosting-Konto selbst tun dürfen</legend><div className="row">{CUSTOMER_ACTIONS.map((a) => <label key={a} className="row small"><input type="checkbox" name={`act-${a}`} style={{ width: 20, minHeight: 20 }} defaultChecked={['panel.login', 'domain.manage', 'email.manage'].includes(a)} />{ACTION_LABEL[a]}</label>)}</div></fieldset>
|
<fieldset style={{ border: 0, padding: 0, margin: '12px 0' }}><legend className="small" style={{ fontWeight: 600, marginBottom: 8 }}>Was Kunden am Hosting-Konto selbst tun dürfen</legend><div className="row">{CUSTOMER_ACTIONS.map((a) => <label key={a} className="row small"><input type="checkbox" name={`act-${a}`} style={{ width: 20, minHeight: 20 }} defaultChecked={['panel.login', 'domain.manage', 'email.manage'].includes(a)} />{ACTION_LABEL[a]}</label>)}</div></fieldset>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@ import { useSession } from '@/lib/session';
|
||||||
import { Alert, Empty, eur, Field, INTERVAL, Status, CUSTOMER_ACTIONS, ACTION_LABEL } from '@/components/ui';
|
import { Alert, Empty, eur, Field, INTERVAL, Status, CUSTOMER_ACTIONS, ACTION_LABEL } from '@/components/ui';
|
||||||
|
|
||||||
export interface Prod { id: string; sku: string; category: string; status: string; connectorInstanceId: string | null; connectorName: string | null; orderableByCustomer: boolean; requiresApproval: boolean; customerActions: string[]; provisioning: Record<string, unknown>;
|
export interface Prod { id: string; sku: string; category: string; status: string; connectorInstanceId: string | null; connectorName: string | null; orderableByCustomer: boolean; requiresApproval: boolean; customerActions: string[]; provisioning: Record<string, unknown>;
|
||||||
current: { id: string; version: number; name: string; taxBp: number; priceBasis: 'net' | 'gross'; setupCents: number; recurringCents: number; billingInterval: string; termMonths: number; renewal: string; renewalTermMonths: number; noticeDays: number } | null }
|
current: { id: string; version: number; name: string; taxBp: number; priceBasis: 'net' | 'gross'; setupCents: number; recurringCents: number; billingInterval: string; termMonths: number; renewal: string; renewalTermMonths: number; noticeDays: number } | null;
|
||||||
|
termPrices: { termMonths: number; recurringCents: number }[] }
|
||||||
export const CAT: Record<string, string> = { hosting: 'Hosting', license: 'Lizenz', addon: 'Zusatzleistung', service: 'Dienstleistung' };
|
export const CAT: Record<string, string> = { hosting: 'Hosting', license: 'Lizenz', addon: 'Zusatzleistung', service: 'Dienstleistung' };
|
||||||
const PSTATUS: Record<string, string> = { active: 'active', draft: 'invited', retired: 'closed' };
|
const PSTATUS: Record<string, string> = { active: 'active', draft: 'invited', retired: 'closed' };
|
||||||
export default function Produkte() {
|
export default function Produkte() {
|
||||||
|
|
|
||||||
|
|
@ -3,19 +3,20 @@ import { useEffect, useState, type FormEvent } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { api, errMsg } from '@/lib/api';
|
import { api, errMsg } from '@/lib/api';
|
||||||
import { useSession } from '@/lib/session';
|
import { useSession } from '@/lib/session';
|
||||||
import { Alert, Empty, Field, type Price, PriceText } from '@/components/ui';
|
import { Alert, Empty, eur, Field, type Price, PriceText } from '@/components/ui';
|
||||||
|
|
||||||
interface P { id: string; sku: string; category: string; name: string; description: string | null; requiresApproval: boolean; price: Price; termMonths: number; renewal: string; renewalTermMonths: number; noticeDays: number }
|
interface P { id: string; sku: string; category: string; name: string; description: string | null; requiresApproval: boolean; price: Price; termMonths: number; renewal: string; renewalTermMonths: number; noticeDays: number; termPrices: { termMonths: number; price: { net: number; tax: number; gross: number } }[] }
|
||||||
interface Cust { id: string; name: string; customerNumber: string; customerType: 'private' | 'business' }
|
interface Cust { id: string; name: string; customerNumber: string; customerType: 'private' | 'business' }
|
||||||
export default function Bestellen() {
|
export default function Bestellen() {
|
||||||
const { me, can } = useSession(); const r = useRouter(); const staff = can('orders.write');
|
const { me, can } = useSession(); const r = useRouter(); const staff = can('orders.write');
|
||||||
const [custs, setCusts] = useState<Cust[]>([]); const [org, setOrg] = useState(''); const [cat, setCat] = useState<P[] | null>(null); const [sel, setSel] = useState<P | null>(null); const [err, setErr] = useState(''); const [busy, setBusy] = useState(false);
|
const [custs, setCusts] = useState<Cust[]>([]); const [org, setOrg] = useState(''); const [cat, setCat] = useState<P[] | null>(null); const [sel, setSel] = useState<P | null>(null); const [term, setTerm] = useState<number | ''>(''); const [err, setErr] = useState(''); const [busy, setBusy] = useState(false);
|
||||||
useEffect(() => { if (staff) api<Cust[]>('GET', '/admin/customers').then((l) => { setCusts(l); setOrg(l[0]?.id ?? ''); }).catch((e) => setErr(errMsg(e))); else if (me?.organizations[0]) setOrg(me.organizations[0].id); }, [staff, me]);
|
useEffect(() => { if (staff) api<Cust[]>('GET', '/admin/customers').then((l) => { setCusts(l); setOrg(l[0]?.id ?? ''); }).catch((e) => setErr(errMsg(e))); else if (me?.organizations[0]) setOrg(me.organizations[0].id); }, [staff, me]);
|
||||||
useEffect(() => { if (org) api<P[]>('GET', `/catalog?org=${org}`).then(setCat).catch((e) => setErr(errMsg(e))); }, [org]);
|
useEffect(() => { if (org) api<P[]>('GET', `/catalog?org=${org}`).then(setCat).catch((e) => setErr(errMsg(e))); }, [org]);
|
||||||
const priv = (cat?.[0] as (P & { customerType?: string }) | undefined)?.customerType === 'private';
|
const priv = (cat?.[0] as (P & { customerType?: string }) | undefined)?.customerType === 'private';
|
||||||
|
const selPrice: Price | null = sel && (term === '' ? sel.price : { ...sel.price, recurring: sel.termPrices.find((t) => t.termMonths === term)!.price });
|
||||||
async function order(e: FormEvent<HTMLFormElement>) {
|
async function order(e: FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault(); if (!sel) return; setBusy(true); setErr(''); const f = new FormData(e.currentTarget);
|
e.preventDefault(); if (!sel) return; setBusy(true); setErr(''); const f = new FormData(e.currentTarget);
|
||||||
try { const o = await api<{ status: string; number: string }>('POST', '/orders', { orgId: org, note: (f.get('note') as string) || undefined, items: [{ productId: sel.id, quantity: 1, ...(staff ? { discountBp: Math.round(Number(f.get('discount') || 0) * 100) } : {}) }] }); r.push(`/bestellungen?neu=${o.number}`); }
|
try { const o = await api<{ status: string; number: string }>('POST', '/orders', { orgId: org, note: (f.get('note') as string) || undefined, items: [{ productId: sel.id, quantity: 1, ...(term !== '' ? { termMonths: term } : {}), ...(staff ? { discountBp: Math.round(Number(f.get('discount') || 0) * 100) } : {}) }] }); r.push(`/bestellungen?neu=${o.number}`); }
|
||||||
catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
|
catch (x) { setErr(errMsg(x)); } finally { setBusy(false); }
|
||||||
}
|
}
|
||||||
return (<>
|
return (<>
|
||||||
|
|
@ -25,8 +26,15 @@ export default function Bestellen() {
|
||||||
<div className="grid">{cat.map((p) => <div className="card" key={p.id} style={sel?.id === p.id ? { borderColor: 'var(--accent)' } : undefined}><h2>{p.name}</h2>{p.description && <p className="muted">{p.description}</p>}
|
<div className="grid">{cat.map((p) => <div className="card" key={p.id} style={sel?.id === p.id ? { borderColor: 'var(--accent)' } : undefined}><h2>{p.name}</h2>{p.description && <p className="muted">{p.description}</p>}
|
||||||
<div className="small"><PriceText p={p.price} priv={priv} /></div>
|
<div className="small"><PriceText p={p.price} priv={priv} /></div>
|
||||||
<p className="small muted">{p.termMonths > 0 ? `Mindestlaufzeit ${p.termMonths} Monate` : 'Keine Mindestlaufzeit'}{p.renewal === 'auto' ? `, Verlängerung um ${p.renewalTermMonths} Monat(e)` : ''}, Kündigungsfrist {p.noticeDays} Tage</p>
|
<p className="small muted">{p.termMonths > 0 ? `Mindestlaufzeit ${p.termMonths} Monate` : 'Keine Mindestlaufzeit'}{p.renewal === 'auto' ? `, Verlängerung um ${p.renewalTermMonths} Monat(e)` : ''}, Kündigungsfrist {p.noticeDays} Tage</p>
|
||||||
<button className="btn" aria-pressed={sel?.id === p.id} onClick={() => setSel(p)}>{sel?.id === p.id ? 'Ausgewählt' : 'Auswählen'}</button></div>)}</div>
|
<button className="btn" aria-pressed={sel?.id === p.id} onClick={() => { setSel(p); setTerm(''); }}>{sel?.id === p.id ? 'Ausgewählt' : 'Auswählen'}</button></div>)}</div>
|
||||||
{sel && <form className="card" onSubmit={order}><h2>Bestellung prüfen</h2><p><strong>{sel.name}</strong></p><div className="small" style={{ marginBottom: 16 }}><PriceText p={sel.price} priv={priv} /></div>
|
{sel && selPrice && <form className="card" onSubmit={order}><h2>Bestellung prüfen</h2><p><strong>{sel.name}</strong></p>
|
||||||
|
{sel.termPrices.length > 0 && <Field id="term" label="Laufzeit" hint="Bei längerer Laufzeit oft günstiger."><select id="term" value={term} onChange={(e) => setTerm(e.target.value === '' ? '' : Number(e.target.value))}><option value="">Standard{sel.termMonths > 0 ? ` (${sel.termMonths} Monate)` : ''}</option>{sel.termPrices.map((t) => <option key={t.termMonths} value={t.termMonths}>{t.termMonths} Monate</option>)}</select></Field>}
|
||||||
|
<div className="small" style={{ marginBottom: 16 }}>
|
||||||
|
{term === '' ? <PriceText p={selPrice} priv={priv} /> : (<>
|
||||||
|
{selPrice.setup.net > 0 && <div>Einrichtung: {priv ? `${eur(selPrice.setup.gross)} inkl. USt.` : `${eur(selPrice.setup.net)} netto (${eur(selPrice.setup.gross)} brutto)`}</div>}
|
||||||
|
<div>Laufzeit {term} Monate, gesamt: {priv ? `${eur(selPrice.recurring.gross)} inkl. USt.` : `${eur(selPrice.recurring.net)} netto (${eur(selPrice.recurring.gross)} brutto)`}</div>
|
||||||
|
</>)}
|
||||||
|
</div>
|
||||||
{staff && <Field id="discount" label="Rabatt in %" hint="Nur Personal. Der Preis wird serverseitig berechnet."><input id="discount" name="discount" type="number" min={0} max={100} step="0.01" defaultValue={0} /></Field>}
|
{staff && <Field id="discount" label="Rabatt in %" hint="Nur Personal. Der Preis wird serverseitig berechnet."><input id="discount" name="discount" type="number" min={0} max={100} step="0.01" defaultValue={0} /></Field>}
|
||||||
<Field id="note" label="Anmerkung (optional)"><input id="note" name="note" maxLength={500} /></Field>
|
<Field id="note" label="Anmerkung (optional)"><input id="note" name="note" maxLength={500} /></Field>
|
||||||
<p className="small muted">{staff ? 'Die Bestellung wird sofort freigegeben und bereitgestellt.' : sel.requiresApproval ? 'Ihre Bestellung wird von uns geprüft und freigegeben. Sie sehen den Stand unter „Bestellungen“.' : 'Die Bereitstellung startet sofort.'}</p>
|
<p className="small muted">{staff ? 'Die Bestellung wird sofort freigegeben und bereitgestellt.' : sel.requiresApproval ? 'Ihre Bestellung wird von uns geprüft und freigegeben. Sie sehen den Stand unter „Bestellungen“.' : 'Die Bereitstellung startet sofort.'}</p>
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ type Row = { description: string; quantity: string; unitPrice: string; taxBp: st
|
||||||
const emptyRow = (): Row => ({ description: '', quantity: '1', unitPrice: '', taxBp: '19' });
|
const emptyRow = (): Row => ({ description: '', quantity: '1', unitPrice: '', taxBp: '19' });
|
||||||
interface Suggested { description: string; quantity: number; unitPriceNetCents: number; taxBp: number }
|
interface Suggested { description: string; quantity: number; unitPriceNetCents: number; taxBp: number }
|
||||||
const toRow = (it: Suggested): Row => ({ description: it.description, quantity: String(it.quantity), unitPrice: (it.unitPriceNetCents / 100).toFixed(2).replace('.', ','), taxBp: String(it.taxBp / 100) });
|
const toRow = (it: Suggested): Row => ({ description: it.description, quantity: String(it.quantity), unitPrice: (it.unitPriceNetCents / 100).toFixed(2).replace('.', ','), taxBp: String(it.taxBp / 100) });
|
||||||
interface Prod { id: string; status: string; current: { name: string } | null }
|
interface Prod { id: string; status: string; current: { name: string } | null; termPrices: { termMonths: number }[] }
|
||||||
interface Contract { id: string; number: string; productName: string | null }
|
interface Contract { id: string; number: string; productName: string | null }
|
||||||
interface Dom { id: string; domain: string; sellNetCents: number | null }
|
interface Dom { id: string; domain: string; sellNetCents: number | null }
|
||||||
|
|
||||||
|
|
@ -32,9 +32,9 @@ export default function RechnungDetail() {
|
||||||
|
|
||||||
const run = async (fn: () => Promise<unknown>, msg?: string) => { setErr(''); setOk(''); setBusy(true); try { await fn(); if (msg) setOk(msg); void load(); } catch (x) { setErr(errMsg(x)); } finally { setBusy(false); } };
|
const run = async (fn: () => Promise<unknown>, msg?: string) => { setErr(''); setOk(''); setBusy(true); try { await fn(); if (msg) setOk(msg); void load(); } catch (x) { setErr(errMsg(x)); } finally { setBusy(false); } };
|
||||||
function appendSuggested(items: Suggested[]) { setRows((rs) => [...rs.filter((r) => r.description.trim() || r.unitPrice.trim()), ...items.map(toRow)]); }
|
function appendSuggested(items: Suggested[]) { setRows((rs) => [...rs.filter((r) => r.description.trim() || r.unitPrice.trim()), ...items.map(toRow)]); }
|
||||||
async function takeFrom(kind: 'product' | 'contract' | 'domain', pickId: string) {
|
async function takeFrom(kind: 'product' | 'contract' | 'domain', pickId: string, termMonths?: string) {
|
||||||
if (!pickId) return; setErr('');
|
if (!pickId) return; setErr('');
|
||||||
try { const r = await api<{ items: Suggested[] }>('GET', `/invoices/suggest-from-${kind}/${pickId}`); appendSuggested(r.items); } catch (x) { setErr(errMsg(x)); }
|
try { const r = await api<{ items: Suggested[] }>('GET', `/invoices/suggest-from-${kind}/${pickId}${termMonths ? `?termMonths=${termMonths}` : ''}`); appendSuggested(r.items); } catch (x) { setErr(errMsg(x)); }
|
||||||
}
|
}
|
||||||
async function saveItems(e: FormEvent<HTMLFormElement>) {
|
async function saveItems(e: FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -55,7 +55,11 @@ export default function RechnungDetail() {
|
||||||
|
|
||||||
{isDraft && inv.canWrite ? (<form className="card" onSubmit={saveItems}><h2>Positionen</h2>
|
{isDraft && inv.canWrite ? (<form className="card" onSubmit={saveItems}><h2>Positionen</h2>
|
||||||
{(products.length > 0 || contracts.length > 0 || domains.length > 0) && <div className="cols" style={{ marginBottom: 16 }}>
|
{(products.length > 0 || contracts.length > 0 || domains.length > 0) && <div className="cols" style={{ marginBottom: 16 }}>
|
||||||
{products.length > 0 && <Field id="fromProduct" label="Position aus Produkt übernehmen" hint="Fügt Einrichtung und/oder wiederkehrenden Preis als eigene Zeilen hinzu."><select id="fromProduct" value="" onChange={(e) => void takeFrom('product', e.target.value)}><option value="">Produkt wählen …</option>{products.map((p) => <option key={p.id} value={p.id}>{p.current!.name}</option>)}</select></Field>}
|
{products.length > 0 && <Field id="fromProduct" label="Position aus Produkt übernehmen" hint="Fügt Einrichtung und/oder wiederkehrenden Preis als eigene Zeilen hinzu. Bei Laufzeit-Staffelpreisen die gewünschte Laufzeit wählen."><select id="fromProduct" value="" onChange={(e) => { const [pid, tm] = e.target.value.split('|'); void takeFrom('product', pid!, tm); }}><option value="">Produkt wählen …</option>
|
||||||
|
{products.map((p) => p.termPrices.length > 0
|
||||||
|
? <optgroup key={p.id} label={p.current!.name}>{p.termPrices.map((t) => <option key={t.termMonths} value={`${p.id}|${t.termMonths}`}>{t.termMonths} Monate</option>)}</optgroup>
|
||||||
|
: <option key={p.id} value={p.id}>{p.current!.name}</option>)}
|
||||||
|
</select></Field>}
|
||||||
{contracts.length > 0 && <Field id="fromContract" label="Position aus laufendem Vertrag übernehmen" hint="Übernimmt den wiederkehrenden Preis der aktuellen Periode."><select id="fromContract" value="" onChange={(e) => void takeFrom('contract', e.target.value)}><option value="">Vertrag wählen …</option>{contracts.map((c) => <option key={c.id} value={c.id}>{c.number} · {c.productName ?? '–'}</option>)}</select></Field>}
|
{contracts.length > 0 && <Field id="fromContract" label="Position aus laufendem Vertrag übernehmen" hint="Übernimmt den wiederkehrenden Preis der aktuellen Periode."><select id="fromContract" value="" onChange={(e) => void takeFrom('contract', e.target.value)}><option value="">Vertrag wählen …</option>{contracts.map((c) => <option key={c.id} value={c.id}>{c.number} · {c.productName ?? '–'}</option>)}</select></Field>}
|
||||||
{domains.length > 0 && <Field id="fromDomain" label="Position aus Domain übernehmen" hint="Verkaufspreis aus der Domain-Aufstellung, nie der Einkauf."><select id="fromDomain" value="" onChange={(e) => void takeFrom('domain', e.target.value)}><option value="">Domain wählen …</option>{domains.map((d) => <option key={d.id} value={d.id} disabled={d.sellNetCents === null}>{d.domain}{d.sellNetCents === null ? ' (kein Verkaufspreis)' : ''}</option>)}</select></Field>}
|
{domains.length > 0 && <Field id="fromDomain" label="Position aus Domain übernehmen" hint="Verkaufspreis aus der Domain-Aufstellung, nie der Einkauf."><select id="fromDomain" value="" onChange={(e) => void takeFrom('domain', e.target.value)}><option value="">Domain wählen …</option>{domains.map((d) => <option key={d.id} value={d.id} disabled={d.sellNetCents === null}>{d.domain}{d.sellNetCents === null ? ' (kein Verkaufspreis)' : ''}</option>)}</select></Field>}
|
||||||
</div>}
|
</div>}
|
||||||
|
|
|
||||||
9
migrations/018_product_term_prices.sql
Normal file
9
migrations/018_product_term_prices.sql
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
-- Staffelpreise nach Laufzeit (z. B. 1/3/6/12/24 Monate), zusätzlich zum Basispreis der Version.
|
||||||
|
-- Optional: ohne Einträge verhält sich ein Produkt genau wie bisher (ein fester Preis).
|
||||||
|
CREATE TABLE product_term_prices (
|
||||||
|
id CHAR(36) PRIMARY KEY,
|
||||||
|
product_version_id CHAR(36) NOT NULL REFERENCES product_versions(id),
|
||||||
|
term_months INT NOT NULL,
|
||||||
|
recurring_cents INT NOT NULL, -- Preis für die gesamte gewählte Laufzeit (nicht pro Monat)
|
||||||
|
UNIQUE KEY uq_ptp (product_version_id, term_months)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
Loading…
Add table
Add a link
Reference in a new issue