Hosting-Produkte: Staffelpreise nach Laufzeit (1/3/6/12/24 Monate)

This commit is contained in:
Kundencenter 2026-09-27 18:35:34 +02:00
parent e0fd57532d
commit 5efe37ecc0
12 changed files with 145 additions and 28 deletions

View file

@ -113,3 +113,20 @@ describe('Rechnungspositionen aus der Domain-Aufstellung', () => {
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);
});
});

View file

@ -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);
});
});
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 });
});
});

View file

@ -3,7 +3,7 @@ import mysql from 'mysql2/promise';
import '../src/core/config.js';
/** 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 () => {
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' });