feat: Preistabelle auf App-Nutzer-Gebühr umgestellt, Rechnungsprüf-Dialog mit E-Mail-Versand und Rabatt-Erfassung

- Preistabelle: App-Nutzungslizenz und Umsatzbeteiligung durch Gebühr pro App-Nutzer ersetzt, Abrechnung pro konfiguriertem App-Nutzer (countByErstelltVon)
- Rechnungen erstellen: Prüf-Dialog mit Vor/Zurück-Navigation, Entfernen einzelner Rechnungen und Versand als PDF-Anhang an die Kunden (EmailService mit Anhang-Unterstützung)
- Rabatt pro Kunde über Icon-Dialog mit Prozentwert und Grund, als eigene Position auf der Rechnung berücksichtigt
- E-Mail-Spalte entfernt, Rechnung-erstellen-Button durch Icon ersetzt, (0 App-Nutzer)-Zusatz entfällt
- Version 0.9.20

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 15:35:20 +02:00
parent a7ea4fa803
commit ef019eecee
17 changed files with 552 additions and 130 deletions

View File

@@ -11,7 +11,7 @@
<packaging>jar</packaging>
<properties>
<revision>0.9.18</revision>
<revision>0.9.20</revision>
<java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>

View File

@@ -10,18 +10,18 @@ public class PriceTable {
private String id;
private String monthlyBasePackage;
private String appUsageLicense;
private String revenueParticipation;
/** Fee billed per app user set up by the customer (unit price per month). */
private String appUserFee;
private String statisticalEvaluation;
public PriceTable() {
}
public PriceTable(String monthlyBasePackage, String appUsageLicense, String revenueParticipation,
String statisticalEvaluation) {
public PriceTable(String monthlyBasePackage, String appUserFee, String statisticalEvaluation) {
this.monthlyBasePackage = monthlyBasePackage;
this.appUsageLicense = appUsageLicense;
this.revenueParticipation = revenueParticipation;
this.appUserFee = appUserFee;
this.statisticalEvaluation = statisticalEvaluation;
}
@@ -41,20 +41,12 @@ public class PriceTable {
this.monthlyBasePackage = monthlyBasePackage;
}
public String getAppUsageLicense() {
return appUsageLicense;
public String getAppUserFee() {
return appUserFee;
}
public void setAppUsageLicense(String appUsageLicense) {
this.appUsageLicense = appUsageLicense;
}
public String getRevenueParticipation() {
return revenueParticipation;
}
public void setRevenueParticipation(String revenueParticipation) {
this.revenueParticipation = revenueParticipation;
public void setAppUserFee(String appUserFee) {
this.appUserFee = appUserFee;
}
public String getStatisticalEvaluation() {

View File

@@ -8,9 +8,15 @@ import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.IFrame;
import com.vaadin.flow.component.html.Main;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.select.Select;
import com.vaadin.flow.component.textfield.NumberField;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.router.HasDynamicTitle;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.theme.lumo.LumoUtility;
@@ -20,9 +26,11 @@ import de.assecutor.votianlt.model.invoices.SystemInvoiceData;
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
import de.assecutor.votianlt.pages.base.ui.view.AdminLayout;
import de.assecutor.votianlt.repository.AppUserRepository;
import de.assecutor.votianlt.repository.PriceTableRepository;
import de.assecutor.votianlt.repository.UserRepository;
import de.assecutor.votianlt.service.CustomerInvoiceService;
import de.assecutor.votianlt.service.EmailService;
import de.assecutor.votianlt.service.InvoiceTemplateService;
import de.assecutor.votianlt.service.SystemCompanyProfileService;
import de.assecutor.votianlt.util.DateTimeFormatUtil;
@@ -59,21 +67,33 @@ public class AdminCreateInvoicesView extends Main implements HasDynamicTitle {
private final UserRepository userRepository;
private final PriceTableRepository priceTableRepository;
private final AppUserRepository appUserRepository;
private final InvoiceTemplateService invoiceTemplateService;
private final CustomerInvoiceService customerInvoiceService;
private final SystemCompanyProfileService systemCompanyProfileService;
private final EmailService emailService;
/** Billing month, selectable by the user; defaults to the current month. */
private YearMonth billingMonth = YearMonth.now();
/** Discount with reason per customer (user id), entered via the dialog. */
private final Map<org.bson.types.ObjectId, DiscountEntry> discounts = new HashMap<>();
/** A discount entered for one customer: percent value plus reason. */
private record DiscountEntry(Double percent, String reason) {
}
public AdminCreateInvoicesView(UserRepository userRepository, PriceTableRepository priceTableRepository,
InvoiceTemplateService invoiceTemplateService, CustomerInvoiceService customerInvoiceService,
SystemCompanyProfileService systemCompanyProfileService) {
AppUserRepository appUserRepository, InvoiceTemplateService invoiceTemplateService,
CustomerInvoiceService customerInvoiceService, SystemCompanyProfileService systemCompanyProfileService,
EmailService emailService) {
this.userRepository = userRepository;
this.priceTableRepository = priceTableRepository;
this.appUserRepository = appUserRepository;
this.invoiceTemplateService = invoiceTemplateService;
this.customerInvoiceService = customerInvoiceService;
this.systemCompanyProfileService = systemCompanyProfileService;
this.emailService = emailService;
setSizeFull();
addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN,
@@ -110,8 +130,6 @@ public class AdminCreateInvoicesView extends Main implements HasDynamicTitle {
grid.setItems(loadBillingInfos());
grid.addColumn(info -> customerDisplayName(info.user()))
.setHeader(getTranslation("admincreateinvoices.column.customer")).setFlexGrow(2).setSortable(true);
grid.addColumn(info -> info.user().getEmail()).setHeader(getTranslation("admincreateinvoices.column.email"))
.setFlexGrow(2);
grid.addColumn(info -> info.startDate() != null ? DateTimeFormatUtil.formatDate(info.startDate()) : "")
.setHeader(getTranslation("admincreateinvoices.column.start")).setFlexGrow(1).setSortable(true);
grid.addColumn(info -> info.billedDays() < info.daysInMonth()
@@ -120,12 +138,29 @@ public class AdminCreateInvoicesView extends Main implements HasDynamicTitle {
.setHeader(getTranslation("admincreateinvoices.column.days")).setFlexGrow(1);
grid.addColumn(info -> DateTimeFormatUtil.formatDate(info.dueDate()))
.setHeader(getTranslation("admincreateinvoices.column.due")).setFlexGrow(1);
grid.addColumn(info -> formatAmount(info.netTotal()) + "")
grid.addColumn(info -> formatAmount(applyDiscount(info).netTotal()) + "")
.setHeader(getTranslation("admincreateinvoices.column.net")).setFlexGrow(1)
.setTextAlign(com.vaadin.flow.component.grid.ColumnTextAlign.END);
grid.addComponentColumn(info -> {
Button createButton = new Button(getTranslation("admincreateinvoices.button.create"));
createButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SMALL);
DiscountEntry entry = discounts.get(info.user().getId());
Button discountButton = new Button(new Icon(VaadinIcon.TAG));
discountButton.addThemeVariants(ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_ICON,
entry != null ? ButtonVariant.LUMO_PRIMARY : ButtonVariant.LUMO_TERTIARY);
String tooltip = entry != null
? getTranslation("admincreateinvoices.discount.position", formatPercent(entry.percent()))
+ (entry.reason().isBlank() ? "" : " " + entry.reason())
: getTranslation("admincreateinvoices.discount.dialog.title");
discountButton.setTooltipText(tooltip);
discountButton.setAriaLabel(getTranslation("admincreateinvoices.discount.dialog.title"));
discountButton.addClickListener(e -> openDiscountDialog(info, grid));
return discountButton;
}).setHeader(getTranslation("admincreateinvoices.column.discount")).setAutoWidth(true).setFlexGrow(0);
grid.addComponentColumn(info -> {
Button createButton = new Button(new Icon(VaadinIcon.INVOICE));
createButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SMALL,
ButtonVariant.LUMO_ICON);
createButton.setTooltipText(getTranslation("admincreateinvoices.button.create"));
createButton.setAriaLabel(getTranslation("admincreateinvoices.button.create"));
createButton.addClickListener(e -> createInvoicePdf(info));
return createButton;
}).setAutoWidth(true).setFlexGrow(0).setFrozenToEnd(true);
@@ -135,10 +170,11 @@ public class AdminCreateInvoicesView extends Main implements HasDynamicTitle {
createSelectedButton.setText(getTranslation("admincreateinvoices.button.createselected", count));
createSelectedButton.setEnabled(count > 0);
});
createSelectedButton.addClickListener(e -> createInvoicesPdf(new ArrayList<>(grid.getSelectedItems())));
createSelectedButton.addClickListener(e -> openInvoiceReviewDialog(new ArrayList<>(grid.getSelectedItems())));
monthSelect.addValueChangeListener(event -> {
if (event.getValue() != null) {
billingMonth = event.getValue();
discounts.clear();
grid.deselectAll();
grid.setItems(loadBillingInfos());
}
@@ -213,23 +249,32 @@ public class AdminCreateInvoicesView extends Main implements HasDynamicTitle {
BigDecimal factor = new BigDecimal(billedDays).divide(new BigDecimal(daysInMonth), 10, RoundingMode.HALF_UP);
boolean proRata = billedDays < daysInMonth;
// Number of app users belonging to this customer in app_user: the
// per-app-user fee is billed once per configured app user
long appUserCount = user.getId() != null ? appUserRepository.countByErstelltVon(user.getId()) : 0;
List<Map<String, String>> positions = new ArrayList<>();
BigDecimal netTotal = BigDecimal.ZERO;
String[][] entries = {
// Position entries: label, configured value, quantity
Object[][] entries = {
{ getTranslation("adminpricetable.field.monthly"),
priceTable != null ? priceTable.getMonthlyBasePackage() : null },
{ getTranslation("adminpricetable.field.applicense"),
priceTable != null ? priceTable.getAppUsageLicense() : null },
{ getTranslation("adminpricetable.field.revenue"),
priceTable != null ? priceTable.getRevenueParticipation() : null } };
priceTable != null ? priceTable.getMonthlyBasePackage() : null, 1L },
{ getTranslation("adminpricetable.field.appuserfee"),
priceTable != null ? priceTable.getAppUserFee() : null, appUserCount } };
for (String[] entry : entries) {
String rawValue = entry[1] != null ? entry[1].trim() : "";
for (Object[] entry : entries) {
String label = (String) entry[0];
String rawValue = entry[1] != null ? ((String) entry[1]).trim() : "";
long quantity = (Long) entry[2];
BigDecimal amount = parseAmount(rawValue);
Map<String, String> position = new HashMap<>();
if (amount != null) {
BigDecimal billed = amount.multiply(factor).setScale(2, RoundingMode.HALF_UP);
String name = entry[0];
BigDecimal billed = amount.multiply(new BigDecimal(quantity)).multiply(factor).setScale(2,
RoundingMode.HALF_UP);
String name = label;
if (quantity > 1) {
name += " " + getTranslation("admincreateinvoices.appusers.suffix", quantity);
}
if (proRata) {
name += " " + getTranslation("admincreateinvoices.prorata.suffix", billedDays, daysInMonth);
}
@@ -237,9 +282,9 @@ public class AdminCreateInvoicesView extends Main implements HasDynamicTitle {
position.put("netAmount", formatAmount(billed));
netTotal = netTotal.add(billed);
} else {
// Non-monetary values (e.g. "15 %" revenue participation) are
// shown as-is and are not part of the totals
position.put("name", entry[0]);
// Non-monetary values are shown as-is and are not part of the
// totals
position.put("name", label);
position.put("netAmount", rawValue.isEmpty() ? "-" : rawValue);
}
positions.add(position);
@@ -252,6 +297,85 @@ public class AdminCreateInvoicesView extends Main implements HasDynamicTitle {
grossTotal);
}
/**
* Opens the dialog to enter (or change) the discount and its reason for
* one customer. An empty or zero discount removes the entry.
*/
private void openDiscountDialog(BillingInfo info, Grid<BillingInfo> grid) {
Dialog dialog = DialogStylingHelper
.createStyledDialog(getTranslation("admincreateinvoices.discount.dialog.title"), "420px");
DiscountEntry entry = discounts.get(info.user().getId());
NumberField percentField = new NumberField(getTranslation("admincreateinvoices.discount.dialog.percent"));
percentField.setMin(0);
percentField.setMax(100);
percentField.setSuffixComponent(new Span("%"));
percentField.setWidthFull();
if (entry != null) {
percentField.setValue(entry.percent());
}
TextField reasonField = new TextField(getTranslation("admincreateinvoices.discount.dialog.reason"));
reasonField.setWidthFull();
if (entry != null) {
reasonField.setValue(entry.reason());
}
VerticalLayout content = DialogStylingHelper.createContentLayout("380px");
content.add(percentField, reasonField);
Button cancelButton = new Button(getTranslation("button.cancel"), e -> dialog.close());
cancelButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
Button applyButton = new Button(getTranslation("admincreateinvoices.discount.dialog.apply"), e -> {
Double percent = percentField.getValue();
// Clamp manual input to the valid range 0-100
if (percent != null && percent > 100) {
percent = 100d;
}
if (percent != null && percent > 0) {
discounts.put(info.user().getId(), new DiscountEntry(percent,
reasonField.getValue() != null ? reasonField.getValue().trim() : ""));
} else {
discounts.remove(info.user().getId());
}
dialog.close();
grid.getDataProvider().refreshItem(info);
});
applyButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
dialog.add(DialogStylingHelper.wrapContent(content));
dialog.getFooter().add(cancelButton, applyButton);
dialog.open();
}
/**
* Applies the discount entered for this customer: adds a negative
* discount position (including the reason) and recalculates the totals.
* Returns the info unchanged if no discount is set.
*/
private BillingInfo applyDiscount(BillingInfo info) {
DiscountEntry entry = discounts.get(info.user().getId());
if (entry == null || entry.percent() == null || entry.percent() <= 0) {
return info;
}
BigDecimal discountAmount = info.netTotal().multiply(BigDecimal.valueOf(entry.percent()))
.divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP);
List<Map<String, String>> positions = new ArrayList<>(info.positions());
Map<String, String> position = new HashMap<>();
String name = getTranslation("admincreateinvoices.discount.position", formatPercent(entry.percent()));
if (!entry.reason().isBlank()) {
name += " " + entry.reason();
}
position.put("name", name);
position.put("netAmount", "-" + formatAmount(discountAmount));
positions.add(position);
BigDecimal netTotal = info.netTotal().subtract(discountAmount);
BigDecimal vatTotal = netTotal.multiply(SYSTEM_VAT_RATE).setScale(2, RoundingMode.HALF_UP);
return new BillingInfo(info.user(), info.startDate(), info.billedDays(), info.daysInMonth(), info.dueDate(),
positions, netTotal, vatTotal, netTotal.add(vatTotal));
}
// ==========================================
// PDF generation
// ==========================================
@@ -261,6 +385,7 @@ public class AdminCreateInvoicesView extends Main implements HasDynamicTitle {
if (templateData == null) {
return;
}
info = applyDiscount(info);
try {
byte[] pdfBytes = customerInvoiceService.generatePdfFromCanvasTemplateWithData(templateData,
buildInvoiceVariables(info), null);
@@ -274,11 +399,16 @@ public class AdminCreateInvoicesView extends Main implements HasDynamicTitle {
}
}
/** One generated invoice held in the review dialog. */
private record InvoicePreview(BillingInfo info, String invoiceNumber, byte[] pdf) {
}
/**
* Creates the invoices of all selected customers as one merged PDF, one
* invoice per page.
* Opens the review dialog for the selected customers: one invoice per
* page, navigable via previous/next, removable from the list and sendable
* to the customers by email.
*/
private void createInvoicesPdf(List<BillingInfo> infos) {
private void openInvoiceReviewDialog(List<BillingInfo> infos) {
if (infos.isEmpty()) {
return;
}
@@ -286,20 +416,142 @@ public class AdminCreateInvoicesView extends Main implements HasDynamicTitle {
if (templateData == null) {
return;
}
List<InvoicePreview> previews = new ArrayList<>();
try {
List<byte[]> pdfs = new ArrayList<>();
for (BillingInfo info : infos) {
pdfs.add(customerInvoiceService.generatePdfFromCanvasTemplateWithData(templateData,
buildInvoiceVariables(info), null));
BillingInfo billed = applyDiscount(info);
previews.add(new InvoicePreview(billed, buildInvoiceNumber(billed.user()), customerInvoiceService
.generatePdfFromCanvasTemplateWithData(templateData, buildInvoiceVariables(billed), null)));
}
String fileName = "rechnungen-" + billingMonth.format(DateTimeFormatter.ofPattern("yyyyMM"));
showPdfInDialog(mergePdfs(pdfs), fileName);
} catch (Exception ex) {
log.error("Error creating system invoices for {} users: {}", infos.size(), ex.getMessage(), ex);
Notification
.show(getTranslation("admincreateinvoices.notification.error", ex.getMessage()), 5000,
Notification.Position.BOTTOM_CENTER)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
return;
}
Dialog dialog = DialogStylingHelper.createStyledDialog(getTranslation("admincreateinvoices.dialog.title"),
"90vw");
dialog.setHeight("90vh");
Paragraph positionLabel = new Paragraph();
positionLabel.addClassNames(LumoUtility.Margin.NONE, LumoUtility.FontWeight.SEMIBOLD,
LumoUtility.Padding.SMALL);
positionLabel.getStyle().set("flex-shrink", "0");
IFrame pdfFrame = new IFrame();
pdfFrame.setWidth("100%");
pdfFrame.getStyle().set("flex", "1").set("min-height", "0").set("border", "none");
Div content = new Div(positionLabel, pdfFrame);
content.getStyle().set("display", "flex").set("flex-direction", "column");
content.setWidth("100%");
content.setHeight("100%");
Button previousButton = new Button(getTranslation("admincreateinvoices.dialog.button.previous"));
previousButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
Button nextButton = new Button(getTranslation("admincreateinvoices.dialog.button.next"));
nextButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
Button deleteButton = new Button(getTranslation("admincreateinvoices.dialog.button.delete"));
deleteButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_ERROR);
Button sendButton = new Button(getTranslation("admincreateinvoices.dialog.button.send"));
sendButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
Button closeButton = new Button(getTranslation("button.close"), e -> dialog.close());
closeButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
int[] index = { 0 };
Runnable updateView = () -> {
InvoicePreview current = previews.get(index[0]);
positionLabel.setText(getTranslation("admincreateinvoices.dialog.position", index[0] + 1, previews.size())
+ " " + customerDisplayName(current.info().user()) + " (" + current.invoiceNumber() + ")");
pdfFrame.getElement().setAttribute("src",
"data:application/pdf;base64," + Base64.getEncoder().encodeToString(current.pdf()));
previousButton.setEnabled(index[0] > 0);
nextButton.setEnabled(index[0] < previews.size() - 1);
};
previousButton.addClickListener(e -> {
if (index[0] > 0) {
index[0]--;
updateView.run();
}
});
nextButton.addClickListener(e -> {
if (index[0] < previews.size() - 1) {
index[0]++;
updateView.run();
}
});
deleteButton.addClickListener(e -> {
previews.remove(index[0]);
Notification.show(getTranslation("admincreateinvoices.dialog.notification.removed"), 3000,
Notification.Position.BOTTOM_CENTER);
if (previews.isEmpty()) {
dialog.close();
return;
}
index[0] = Math.min(index[0], previews.size() - 1);
updateView.run();
});
sendButton.addClickListener(e -> {
sendButton.setEnabled(false);
sendInvoices(previews);
dialog.close();
});
updateView.run();
dialog.add(DialogStylingHelper.wrapContent(content, true));
dialog.getFooter().add(previousButton, nextButton, deleteButton, sendButton, closeButton);
dialog.open();
}
/**
* Sends each invoice as PDF attachment to the respective customer's email
* address and reports how many mails were (not) sent.
*/
private void sendInvoices(List<InvoicePreview> previews) {
String issuerName = "";
try {
issuerName = safe(systemCompanyProfileService.createSystemInvoiceData().getCompanyName());
} catch (Exception ex) {
log.warn("Could not load system company profile for invoice mail: {}", ex.getMessage());
}
String monthLabel = billingMonth.format(DateTimeFormatter.ofPattern("LLLL yyyy", getLocale()));
int sent = 0;
int failed = 0;
for (InvoicePreview preview : previews) {
String email = preview.info().user().getEmail();
if (email == null || email.isBlank()) {
log.warn("Customer {} has no email address, invoice {} not sent",
customerDisplayName(preview.info().user()), preview.invoiceNumber());
failed++;
continue;
}
try {
emailService.sendEmailWithAttachment(email,
getTranslation("admincreateinvoices.mail.subject", preview.invoiceNumber()),
getTranslation("admincreateinvoices.mail.body", preview.invoiceNumber(), monthLabel,
issuerName),
preview.pdf(), preview.invoiceNumber() + ".pdf");
sent++;
} catch (Exception ex) {
log.error("Error sending invoice {} to {}: {}", preview.invoiceNumber(), email, ex.getMessage(), ex);
failed++;
}
}
if (sent > 0) {
Notification
.show(getTranslation("admincreateinvoices.notification.sent", sent), 5000,
Notification.Position.BOTTOM_CENTER)
.addThemeVariants(NotificationVariant.LUMO_SUCCESS);
}
if (failed > 0) {
Notification
.show(getTranslation("admincreateinvoices.notification.sendfailed", failed), 5000,
Notification.Position.BOTTOM_CENTER)
.addThemeVariants(NotificationVariant.LUMO_ERROR);
}
}
@@ -321,24 +573,6 @@ public class AdminCreateInvoicesView extends Main implements HasDynamicTitle {
return template.get().getTemplateData();
}
private byte[] mergePdfs(List<byte[]> pdfs) throws Exception {
if (pdfs.size() == 1) {
return pdfs.get(0);
}
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
com.itextpdf.kernel.pdf.PdfDocument merged = new com.itextpdf.kernel.pdf.PdfDocument(
new com.itextpdf.kernel.pdf.PdfWriter(baos));
com.itextpdf.kernel.utils.PdfMerger merger = new com.itextpdf.kernel.utils.PdfMerger(merged);
for (byte[] pdf : pdfs) {
com.itextpdf.kernel.pdf.PdfDocument source = new com.itextpdf.kernel.pdf.PdfDocument(
new com.itextpdf.kernel.pdf.PdfReader(new java.io.ByteArrayInputStream(pdf)));
merger.merge(source, 1, source.getNumberOfPages());
source.close();
}
merged.close();
return baos.toByteArray();
}
private Map<String, String> buildInvoiceVariables(BillingInfo info) throws Exception {
SystemInvoiceData issuer = systemCompanyProfileService.createSystemInvoiceData();
User user = info.user();
@@ -468,6 +702,11 @@ public class AdminCreateInvoicesView extends Main implements HasDynamicTitle {
return amount.setScale(2, RoundingMode.HALF_UP).toString().replace(".", ",");
}
/** Formats a percent value without trailing zeros, e.g. 10 or 12,5. */
private String formatPercent(Double percent) {
return new BigDecimal(percent.toString()).stripTrailingZeros().toPlainString().replace('.', ',');
}
private String safe(String value) {
return value != null ? value : "";
}

View File

@@ -1,7 +1,6 @@
package de.assecutor.votianlt.pages.view;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.TextField;
@@ -19,8 +18,7 @@ public class AdminPricetableView extends VerticalLayout implements HasDynamicTit
private final PriceTableRepository priceTableRepository;
private final TextField monthlyBasePackage;
private final TextField appUsageLicense;
private final TextField revenueParticipation;
private final TextField appUserFee;
public AdminPricetableView(PriceTableRepository priceTableRepository) {
this.priceTableRepository = priceTableRepository;
@@ -41,29 +39,20 @@ public class AdminPricetableView extends VerticalLayout implements HasDynamicTit
monthlyBasePackage.setWidth("40%");
monthlyBasePackage.setMaxWidth("40%");
appUsageLicense = new TextField();
appUsageLicense.setLabel(getTranslation("adminpricetable.field.applicense"));
appUsageLicense.setWidth("40%");
appUsageLicense.setMaxWidth("40%");
revenueParticipation = new TextField();
revenueParticipation.setLabel(getTranslation("adminpricetable.field.revenue"));
revenueParticipation.setWidth("40%");
revenueParticipation.setMaxWidth("40%");
fieldsLayout.add(monthlyBasePackage, appUsageLicense, revenueParticipation);
add(fieldsLayout);
appUserFee = new TextField();
appUserFee.setLabel(getTranslation("adminpricetable.field.appuserfee"));
appUserFee.setHelperText(getTranslation("adminpricetable.field.appuserfee.helper"));
appUserFee.setWidth("40%");
appUserFee.setMaxWidth("40%");
Button saveButton = new Button(getTranslation("button.savechanges"));
saveButton.getStyle().set("margin-top", "20px");
saveButton.addClickListener(e -> savePriceTable());
saveButton.addThemeVariants(com.vaadin.flow.component.button.ButtonVariant.LUMO_PRIMARY);
Div actions = new Div(saveButton);
actions.addClassNames("form-shell", "simple-card");
fieldsLayout.add(monthlyBasePackage, appUserFee, saveButton);
add(fieldsLayout, actions);
add(fieldsLayout);
// Load existing data
loadPriceTable();
@@ -75,8 +64,7 @@ public class AdminPricetableView extends VerticalLayout implements HasDynamicTit
PriceTable priceTable = priceTableRepository.findAll().stream().findFirst().orElse(new PriceTable());
priceTable.setMonthlyBasePackage(monthlyBasePackage.getValue());
priceTable.setAppUsageLicense(appUsageLicense.getValue());
priceTable.setRevenueParticipation(revenueParticipation.getValue());
priceTable.setAppUserFee(appUserFee.getValue());
priceTableRepository.save(priceTable);
Notification.show(getTranslation("adminpricetable.notification.saved"), 3000,
@@ -94,10 +82,7 @@ public class AdminPricetableView extends VerticalLayout implements HasDynamicTit
if (priceTable != null) {
monthlyBasePackage
.setValue(priceTable.getMonthlyBasePackage() != null ? priceTable.getMonthlyBasePackage() : "");
appUsageLicense
.setValue(priceTable.getAppUsageLicense() != null ? priceTable.getAppUsageLicense() : "");
revenueParticipation.setValue(
priceTable.getRevenueParticipation() != null ? priceTable.getRevenueParticipation() : "");
appUserFee.setValue(priceTable.getAppUserFee() != null ? priceTable.getAppUserFee() : "");
}
} catch (Exception ex) {
Notification.show(getTranslation("adminpricetable.notification.load.error", ex.getMessage()), 5000,

View File

@@ -202,29 +202,40 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi
variables.put("customer.email", "nutzer@beispiel.de");
variables.put("customer.phone", "0123 456789");
// Positionen: die Rechnung an die Nutzer enthält ausschließlich die drei
// Positionen: die Rechnung an die Nutzer enthält ausschließlich die
// Parameter der Preistabelle (/admin-price-table), keine Leistungen
addPriceTablePositions(variables);
return variables;
}
/** Example app user count for the template preview (Musterfirma). */
private static final int SAMPLE_APP_USER_COUNT = 3;
/**
* The three positions of the price table (/admin-price-table): label plus
* configured value. These are the only positions on system invoices.
* The positions of the price table (/admin-price-table): label plus
* configured value. These are the only positions on system invoices. The
* per-app-user fee is shown multiplied by an example user count, since the
* preview works with sample data anyway.
*/
private String[][] priceTableEntries() {
PriceTable priceTable = priceTableRepository.findAll().stream().findFirst().orElse(null);
String feeLabel = getTranslation("adminpricetable.field.appuserfee");
String feeValue = priceTable != null ? priceTable.getAppUserFee() : null;
BigDecimal unitAmount = parseAmount(feeValue != null ? feeValue.trim() : "");
if (unitAmount != null) {
feeLabel += " " + getTranslation("admincreateinvoices.appusers.suffix", SAMPLE_APP_USER_COUNT);
feeValue = formatAmount(unitAmount.multiply(new BigDecimal(SAMPLE_APP_USER_COUNT))) + "";
}
return new String[][] {
{ getTranslation("adminpricetable.field.monthly"),
priceTable != null ? priceTable.getMonthlyBasePackage() : null },
{ getTranslation("adminpricetable.field.applicense"),
priceTable != null ? priceTable.getAppUsageLicense() : null },
{ getTranslation("adminpricetable.field.revenue"),
priceTable != null ? priceTable.getRevenueParticipation() : null } };
{ feeLabel, feeValue } };
}
/**
* JSON for the canvas rendering of the "services.list" block: the three
* JSON for the canvas rendering of the "services.list" block: the
* price table positions plus totals (window.profileInvoiceServiceData).
*/
private String buildCanvasPositionsJson() {
@@ -277,7 +288,7 @@ public class InvoiceGeneratorView extends VerticalLayout implements HasDynamicTi
position.put("netAmount", formatAmount(amount));
netTotal = netTotal.add(amount);
} else {
// Nicht-numerische Werte (z.B. "15 %" Umsatzbeteiligung) unverändert anzeigen
// Nicht-numerische Werte unverändert anzeigen
position.put("netAmount", rawValue.isEmpty() ? "-" : rawValue);
}
positions.add(position);

View File

@@ -23,4 +23,8 @@ public interface AppUserRepository extends MongoRepository<AppUser, ObjectId> {
// Find AppUser by bezeichnung
AppUser findByBezeichnung(String bezeichnung);
// Number of app users belonging to a customer (for per-app-user billing);
// same association as the customer's own app user list (findByErstelltVon)
long countByErstelltVon(ObjectId erstelltVon);
}

View File

@@ -6,12 +6,16 @@ import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.repository.AppUserRepository;
import de.assecutor.votianlt.repository.JobRepository;
import de.assecutor.votianlt.repository.UserRepository;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.bson.types.ObjectId;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@@ -378,6 +382,23 @@ public class EmailService {
mailSender.send(message);
}
/**
* Send a text email with a single attachment. Synchronous on purpose so
* callers can report per-recipient success or failure to the user.
*/
public void sendEmailWithAttachment(String to, String subject, String body, byte[] attachment,
String attachmentName) throws MessagingException {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setFrom(smtpUsername);
helper.setTo(to);
helper.setSubject(subject);
helper.setText(body);
helper.addAttachment(attachmentName, new ByteArrayResource(attachment));
mailSender.send(message);
log.info("Email with attachment {} sent to {}", attachmentName, to);
}
/**
* Send a simple text email
*/

View File

@@ -1051,8 +1051,8 @@ misc.retry=Erneut versuchen
# Admin Price Table
adminpricetable.title=Preistabelle
adminpricetable.field.monthly=Monatliches Basispaket
adminpricetable.field.applicense=App-Nutzungslizenz
adminpricetable.field.revenue=Umsatzbeteiligung
adminpricetable.field.appuserfee=Gebühr pro App-Nutzer
adminpricetable.field.appuserfee.helper=Wird je eingerichtetem App-Nutzer monatlich berechnet
adminpricetable.notification.saved=Preistabelle wurde gespeichert
adminpricetable.notification.save.error=Fehler beim Speichern: {0}
adminpricetable.notification.load.error=Fehler beim Laden: {0}
@@ -1112,7 +1112,12 @@ page.title.admin.createinvoices=Rechnungen erstellen
admincreateinvoices.title=Rechnungen erstellen
admincreateinvoices.hint=Rechnungen werden zum Monatsende (Ultimo) fällig. Beginnt ein Kunde im laufenden Monat, werden nur die verbleibenden Tage bis Ultimo berechnet.
admincreateinvoices.column.customer=Kunde
admincreateinvoices.column.email=E-Mail
admincreateinvoices.column.discount=Rabatt
admincreateinvoices.discount.position=Rabatt ({0} %)
admincreateinvoices.discount.dialog.title=Rabatt erfassen
admincreateinvoices.discount.dialog.percent=Rabatt in %
admincreateinvoices.discount.dialog.reason=Grund für den Rabatt
admincreateinvoices.discount.dialog.apply=Übernehmen
admincreateinvoices.column.start=Kunde seit
admincreateinvoices.column.days=Abgerechnete Tage
admincreateinvoices.column.due=Fällig am
@@ -1120,8 +1125,20 @@ admincreateinvoices.column.net=Nettobetrag
admincreateinvoices.days.full=Voller Monat
admincreateinvoices.days.partial={0} von {1} Tagen
admincreateinvoices.prorata.suffix=(anteilig {0}/{1} Tage)
admincreateinvoices.appusers.suffix=({0} App-Nutzer)
admincreateinvoices.button.create=Rechnung erstellen
admincreateinvoices.notification.notemplate=Kein Rechnungstemplate vorhanden. Bitte zuerst im Rechnungsgenerator ein Template speichern.
admincreateinvoices.notification.error=Fehler beim Erstellen der Rechnung: {0}
admincreateinvoices.button.createselected=Rechnungen erstellen ({0})
admincreateinvoices.field.month=Abrechnungsmonat
admincreateinvoices.dialog.title=Rechnungen prüfen
admincreateinvoices.dialog.position=Rechnung {0} von {1}
admincreateinvoices.dialog.button.previous=Zurück
admincreateinvoices.dialog.button.next=Weiter
admincreateinvoices.dialog.button.delete=Aus Liste entfernen
admincreateinvoices.dialog.button.send=Rechnungen senden
admincreateinvoices.dialog.notification.removed=Rechnung aus der Liste entfernt
admincreateinvoices.mail.subject=Ihre Rechnung {0}
admincreateinvoices.mail.body=Sehr geehrte Damen und Herren,\n\nanbei erhalten Sie Ihre Rechnung {0} für den Abrechnungszeitraum {1}.\n\nMit freundlichen Grüßen\n{2}
admincreateinvoices.notification.sent={0} Rechnung(en) per E-Mail versendet
admincreateinvoices.notification.sendfailed={0} Rechnung(en) konnten nicht versendet werden

View File

@@ -881,8 +881,8 @@ misc.error=Ilmnes viga
misc.retry=Proovi uuesti
adminpricetable.title=Hinnatabel
adminpricetable.field.monthly=Igakuine p\u00f5hipakett
adminpricetable.field.applicense=\u00c4pi kasutuslitsents
adminpricetable.field.revenue=K\u00e4ibest osalus
adminpricetable.field.appuserfee=Tasu äpi kasutaja kohta
adminpricetable.field.appuserfee.helper=Arvestatakse igakuiselt iga seadistatud äpi kasutaja kohta
adminpricetable.notification.saved=Hinnatabel on salvestatud
adminpricetable.notification.save.error=Salvestamisel ilmnes viga: {0}
adminpricetable.notification.load.error=Laadimisel ilmnes viga: {0}
@@ -942,7 +942,12 @@ page.title.admin.createinvoices=Arvete koostamine
admincreateinvoices.title=Arvete koostamine
admincreateinvoices.hint=Arved kuuluvad tasumisele kuu lõpus. Kui klient alustab kuu keskel, arvestatakse ainult kuu lõpuni jäänud päevad.
admincreateinvoices.column.customer=Klient
admincreateinvoices.column.email=E-post
admincreateinvoices.column.discount=Allahindlus
admincreateinvoices.discount.position=Allahindlus ({0}%)
admincreateinvoices.discount.dialog.title=Sisesta allahindlus
admincreateinvoices.discount.dialog.percent=Allahindlus (%)
admincreateinvoices.discount.dialog.reason=Allahindluse põhjus
admincreateinvoices.discount.dialog.apply=Rakenda
admincreateinvoices.column.start=Klient alates
admincreateinvoices.column.days=Arvestatud päevad
admincreateinvoices.column.due=Tähtaeg
@@ -950,8 +955,20 @@ admincreateinvoices.column.net=Netosumma
admincreateinvoices.days.full=Terve kuu
admincreateinvoices.days.partial={0} / {1} päeva
admincreateinvoices.prorata.suffix=(proportsionaalselt {0}/{1} päeva)
admincreateinvoices.appusers.suffix=({0} äpi kasutajat)
admincreateinvoices.button.create=Koosta arve
admincreateinvoices.notification.notemplate=Arve mall puudub. Palun salvestage kõigepealt mall arvegeneraatoris.
admincreateinvoices.notification.error=Viga arve koostamisel: {0}
admincreateinvoices.button.createselected=Koosta arved ({0})
admincreateinvoices.field.month=Arveldusperiood
admincreateinvoices.dialog.title=Arvete ülevaatus
admincreateinvoices.dialog.position=Arve {0} / {1}
admincreateinvoices.dialog.button.previous=Tagasi
admincreateinvoices.dialog.button.next=Edasi
admincreateinvoices.dialog.button.delete=Eemalda loendist
admincreateinvoices.dialog.button.send=Saada arved
admincreateinvoices.dialog.notification.removed=Arve eemaldati loendist
admincreateinvoices.mail.subject=Teie arve {0}
admincreateinvoices.mail.body=Lugupeetud klient,\n\nmanuses on Teie arve {0} arveldusperioodi {1} eest.\n\nLugupidamisega\n{2}
admincreateinvoices.notification.sent={0} arve(t) saadeti e-postiga
admincreateinvoices.notification.sendfailed={0} arve(t) ei õnnestunud saata

View File

@@ -1051,8 +1051,8 @@ misc.retry=Retry
# Admin Price Table
adminpricetable.title=Price Table
adminpricetable.field.monthly=Monthly Base Package
adminpricetable.field.applicense=App Usage License
adminpricetable.field.revenue=Revenue Share
adminpricetable.field.appuserfee=Fee per App User
adminpricetable.field.appuserfee.helper=Billed monthly per app user set up
adminpricetable.notification.saved=Price table has been saved
adminpricetable.notification.save.error=Error saving: {0}
adminpricetable.notification.load.error=Error loading: {0}
@@ -1112,7 +1112,12 @@ page.title.admin.createinvoices=Create invoices
admincreateinvoices.title=Create invoices
admincreateinvoices.hint=Invoices are due at the end of the month. If a customer starts mid-month, only the remaining days until the end of the month are billed.
admincreateinvoices.column.customer=Customer
admincreateinvoices.column.email=Email
admincreateinvoices.column.discount=Discount
admincreateinvoices.discount.position=Discount ({0}%)
admincreateinvoices.discount.dialog.title=Enter discount
admincreateinvoices.discount.dialog.percent=Discount in %
admincreateinvoices.discount.dialog.reason=Reason for the discount
admincreateinvoices.discount.dialog.apply=Apply
admincreateinvoices.column.start=Customer since
admincreateinvoices.column.days=Billed days
admincreateinvoices.column.due=Due on
@@ -1120,8 +1125,20 @@ admincreateinvoices.column.net=Net amount
admincreateinvoices.days.full=Full month
admincreateinvoices.days.partial={0} of {1} days
admincreateinvoices.prorata.suffix=(pro rata {0}/{1} days)
admincreateinvoices.appusers.suffix=({0} app users)
admincreateinvoices.button.create=Create invoice
admincreateinvoices.notification.notemplate=No invoice template available. Please save a template in the invoice generator first.
admincreateinvoices.notification.error=Error creating the invoice: {0}
admincreateinvoices.button.createselected=Create invoices ({0})
admincreateinvoices.field.month=Billing month
admincreateinvoices.dialog.title=Review invoices
admincreateinvoices.dialog.position=Invoice {0} of {1}
admincreateinvoices.dialog.button.previous=Back
admincreateinvoices.dialog.button.next=Next
admincreateinvoices.dialog.button.delete=Remove from list
admincreateinvoices.dialog.button.send=Send invoices
admincreateinvoices.dialog.notification.removed=Invoice removed from the list
admincreateinvoices.mail.subject=Your invoice {0}
admincreateinvoices.mail.body=Dear Sir or Madam,\n\nplease find attached your invoice {0} for the billing period {1}.\n\nKind regards\n{2}
admincreateinvoices.notification.sent={0} invoice(s) sent by email
admincreateinvoices.notification.sendfailed={0} invoice(s) could not be sent

View File

@@ -982,8 +982,8 @@ misc.retry=Reintentar
# Admin Price Table
adminpricetable.title=Tabla de precios
adminpricetable.field.monthly=Paquete b\u00e1sico mensual
adminpricetable.field.applicense=Licencia de uso de la app
adminpricetable.field.revenue=Participaci\u00f3n en los ingresos
adminpricetable.field.appuserfee=Tarifa por usuario de la app
adminpricetable.field.appuserfee.helper=Se factura mensualmente por cada usuario de la app configurado
adminpricetable.notification.saved=La tabla de precios ha sido guardada
adminpricetable.notification.save.error=Error al guardar: {0}
adminpricetable.notification.load.error=Error al cargar: {0}
@@ -1043,7 +1043,12 @@ page.title.admin.createinvoices=Crear facturas
admincreateinvoices.title=Crear facturas
admincreateinvoices.hint=Las facturas vencen a fin de mes. Si un cliente comienza a mitad de mes, solo se facturan los días restantes hasta fin de mes.
admincreateinvoices.column.customer=Cliente
admincreateinvoices.column.email=Correo electrónico
admincreateinvoices.column.discount=Descuento
admincreateinvoices.discount.position=Descuento ({0} %)
admincreateinvoices.discount.dialog.title=Registrar descuento
admincreateinvoices.discount.dialog.percent=Descuento en %
admincreateinvoices.discount.dialog.reason=Motivo del descuento
admincreateinvoices.discount.dialog.apply=Aplicar
admincreateinvoices.column.start=Cliente desde
admincreateinvoices.column.days=Días facturados
admincreateinvoices.column.due=Vence el
@@ -1051,8 +1056,20 @@ admincreateinvoices.column.net=Importe neto
admincreateinvoices.days.full=Mes completo
admincreateinvoices.days.partial={0} de {1} días
admincreateinvoices.prorata.suffix=(prorrateado {0}/{1} días)
admincreateinvoices.appusers.suffix=({0} usuarios de la app)
admincreateinvoices.button.create=Crear factura
admincreateinvoices.notification.notemplate=No hay plantilla de factura. Guarde primero una plantilla en el generador de facturas.
admincreateinvoices.notification.error=Error al crear la factura: {0}
admincreateinvoices.button.createselected=Crear facturas ({0})
admincreateinvoices.field.month=Mes de facturación
admincreateinvoices.dialog.title=Revisar facturas
admincreateinvoices.dialog.position=Factura {0} de {1}
admincreateinvoices.dialog.button.previous=Atrás
admincreateinvoices.dialog.button.next=Siguiente
admincreateinvoices.dialog.button.delete=Quitar de la lista
admincreateinvoices.dialog.button.send=Enviar facturas
admincreateinvoices.dialog.notification.removed=Factura eliminada de la lista
admincreateinvoices.mail.subject=Su factura {0}
admincreateinvoices.mail.body=Estimados señores,\n\nadjunto encontrará su factura {0} correspondiente al período de facturación {1}.\n\nAtentamente\n{2}
admincreateinvoices.notification.sent={0} factura(s) enviada(s) por correo electrónico
admincreateinvoices.notification.sendfailed=No se pudieron enviar {0} factura(s)

View File

@@ -982,8 +982,8 @@ misc.retry=R\u00e9essayer
# Admin Price Table
adminpricetable.title=Tableau des prix
adminpricetable.field.monthly=Forfait mensuel de base
adminpricetable.field.applicense=Licence d'utilisation de l'app
adminpricetable.field.revenue=Participation au chiffre d'affaires
adminpricetable.field.appuserfee=Frais par utilisateur de l'app
adminpricetable.field.appuserfee.helper=Facturé mensuellement par utilisateur de l'app configuré
adminpricetable.notification.saved=Le tableau des prix a \u00e9t\u00e9 enregistr\u00e9
adminpricetable.notification.save.error=Erreur lors de l'enregistrement : {0}
adminpricetable.notification.load.error=Erreur lors du chargement : {0}
@@ -1043,7 +1043,12 @@ page.title.admin.createinvoices=Créer des factures
admincreateinvoices.title=Créer des factures
admincreateinvoices.hint=Les factures sont exigibles en fin de mois. Si un client commence en cours de mois, seuls les jours restants jusqu'à la fin du mois sont facturés.
admincreateinvoices.column.customer=Client
admincreateinvoices.column.email=E-mail
admincreateinvoices.column.discount=Remise
admincreateinvoices.discount.position=Remise ({0} %)
admincreateinvoices.discount.dialog.title=Saisir la remise
admincreateinvoices.discount.dialog.percent=Remise en %
admincreateinvoices.discount.dialog.reason=Motif de la remise
admincreateinvoices.discount.dialog.apply=Appliquer
admincreateinvoices.column.start=Client depuis
admincreateinvoices.column.days=Jours facturés
admincreateinvoices.column.due=Échéance le
@@ -1051,8 +1056,20 @@ admincreateinvoices.column.net=Montant net
admincreateinvoices.days.full=Mois complet
admincreateinvoices.days.partial={0} sur {1} jours
admincreateinvoices.prorata.suffix=(au prorata {0}/{1} jours)
admincreateinvoices.appusers.suffix=({0} utilisateurs de l'app)
admincreateinvoices.button.create=Créer la facture
admincreateinvoices.notification.notemplate=Aucun modèle de facture disponible. Veuillez d'abord enregistrer un modèle dans le générateur de factures.
admincreateinvoices.notification.error=Erreur lors de la création de la facture : {0}
admincreateinvoices.button.createselected=Créer les factures ({0})
admincreateinvoices.field.month=Mois de facturation
admincreateinvoices.dialog.title=Vérifier les factures
admincreateinvoices.dialog.position=Facture {0} sur {1}
admincreateinvoices.dialog.button.previous=Retour
admincreateinvoices.dialog.button.next=Suivant
admincreateinvoices.dialog.button.delete=Retirer de la liste
admincreateinvoices.dialog.button.send=Envoyer les factures
admincreateinvoices.dialog.notification.removed=Facture retirée de la liste
admincreateinvoices.mail.subject=Votre facture {0}
admincreateinvoices.mail.body=Madame, Monsieur,\n\nveuillez trouver ci-joint votre facture {0} pour la période de facturation {1}.\n\nCordialement\n{2}
admincreateinvoices.notification.sent={0} facture(s) envoyée(s) par e-mail
admincreateinvoices.notification.sendfailed={0} facture(s) n''ont pas pu être envoyées

View File

@@ -984,8 +984,8 @@ misc.retry=Bandyti dar kartą
# Admin Price Table
adminpricetable.title=Kainų lentelė
adminpricetable.field.monthly=Mėnesinis bazinis paketas
adminpricetable.field.applicense=Programėlės naudojimo licencija
adminpricetable.field.revenue=Pajamų dalis
adminpricetable.field.appuserfee=Mokestis už programėlės naudotoją
adminpricetable.field.appuserfee.helper=Skaičiuojamas kas mėnesį už kiekvieną sukurtą programėlės naudotoją
adminpricetable.notification.saved=Kainų lentelė išsaugota
adminpricetable.notification.save.error=Klaida išsaugant: {0}
adminpricetable.notification.load.error=Klaida įkeliant: {0}
@@ -1045,7 +1045,12 @@ page.title.admin.createinvoices=Kurti sąskaitas
admincreateinvoices.title=Kurti sąskaitas
admincreateinvoices.hint=Sąskaitos apmokamos mėnesio pabaigoje. Jei klientas pradeda mėnesio viduryje, apmokestinamos tik likusios dienos iki mėnesio pabaigos.
admincreateinvoices.column.customer=Klientas
admincreateinvoices.column.email=El. paštas
admincreateinvoices.column.discount=Nuolaida
admincreateinvoices.discount.position=Nuolaida ({0} %)
admincreateinvoices.discount.dialog.title=Įvesti nuolaidą
admincreateinvoices.discount.dialog.percent=Nuolaida %
admincreateinvoices.discount.dialog.reason=Nuolaidos priežastis
admincreateinvoices.discount.dialog.apply=Taikyti
admincreateinvoices.column.start=Klientas nuo
admincreateinvoices.column.days=Apmokestintos dienos
admincreateinvoices.column.due=Terminas
@@ -1053,8 +1058,20 @@ admincreateinvoices.column.net=Neto suma
admincreateinvoices.days.full=Visas mėnuo
admincreateinvoices.days.partial={0} iš {1} dienų
admincreateinvoices.prorata.suffix=(proporcingai {0}/{1} dienų)
admincreateinvoices.appusers.suffix=({0} programėlės naudotojų)
admincreateinvoices.button.create=Sukurti sąskaitą
admincreateinvoices.notification.notemplate=Nėra sąskaitos šablono. Pirmiausia išsaugokite šabloną sąskaitų generatoriuje.
admincreateinvoices.notification.error=Klaida kuriant sąskaitą: {0}
admincreateinvoices.button.createselected=Sukurti sąskaitas ({0})
admincreateinvoices.field.month=Atsiskaitymo mėnuo
admincreateinvoices.dialog.title=Peržiūrėti sąskaitas
admincreateinvoices.dialog.position=Sąskaita {0} iš {1}
admincreateinvoices.dialog.button.previous=Atgal
admincreateinvoices.dialog.button.next=Toliau
admincreateinvoices.dialog.button.delete=Pašalinti iš sąrašo
admincreateinvoices.dialog.button.send=Siųsti sąskaitas
admincreateinvoices.dialog.notification.removed=Sąskaita pašalinta iš sąrašo
admincreateinvoices.mail.subject=Jūsų sąskaita {0}
admincreateinvoices.mail.body=Gerbiami klientai,\n\npridedame Jūsų sąskaitą {0} už atsiskaitymo laikotarpį {1}.\n\nPagarbiai\n{2}
admincreateinvoices.notification.sent=El. paštu išsiųsta sąskaitų: {0}
admincreateinvoices.notification.sendfailed=Nepavyko išsiųsti sąskaitų: {0}

View File

@@ -982,8 +982,8 @@ misc.retry=Mēģināt vēlreiz
# Admin Price Table
adminpricetable.title=Cenu tabula
adminpricetable.field.monthly=Ikmēneša pamatpakete
adminpricetable.field.applicense=Lietotnes licence
adminpricetable.field.revenue=Ieņēmumu daļa
adminpricetable.field.appuserfee=Maksa par lietotnes lietotāju
adminpricetable.field.appuserfee.helper=Tiek aprēķināta katru mēnesi par katru izveidoto lietotnes lietotāju
adminpricetable.notification.saved=Cenu tabula saglabāta
adminpricetable.notification.save.error=Kļūda saglabājot: {0}
adminpricetable.notification.load.error=Kļūda ielādējot: {0}
@@ -1043,7 +1043,12 @@ page.title.admin.createinvoices=Izveidot rēķinus
admincreateinvoices.title=Izveidot rēķinus
admincreateinvoices.hint=Rēķini jāapmaksā mēneša beigās. Ja klients sāk mēneša vidū, tiek aprēķinātas tikai atlikušās dienas līdz mēneša beigām.
admincreateinvoices.column.customer=Klients
admincreateinvoices.column.email=E-pasts
admincreateinvoices.column.discount=Atlaide
admincreateinvoices.discount.position=Atlaide ({0} %)
admincreateinvoices.discount.dialog.title=Ievadīt atlaidi
admincreateinvoices.discount.dialog.percent=Atlaide %
admincreateinvoices.discount.dialog.reason=Atlaides iemesls
admincreateinvoices.discount.dialog.apply=Piemērot
admincreateinvoices.column.start=Klients kopš
admincreateinvoices.column.days=Aprēķinātās dienas
admincreateinvoices.column.due=Termiņš
@@ -1051,8 +1056,20 @@ admincreateinvoices.column.net=Neto summa
admincreateinvoices.days.full=Pilns mēnesis
admincreateinvoices.days.partial={0} no {1} dienām
admincreateinvoices.prorata.suffix=(proporcionāli {0}/{1} dienas)
admincreateinvoices.appusers.suffix=({0} lietotnes lietotāji)
admincreateinvoices.button.create=Izveidot rēķinu
admincreateinvoices.notification.notemplate=Nav rēķina veidnes. Lūdzu, vispirms saglabājiet veidni rēķinu ģeneratorā.
admincreateinvoices.notification.error=Kļūda, veidojot rēķinu: {0}
admincreateinvoices.button.createselected=Izveidot rēķinus ({0})
admincreateinvoices.field.month=Norēķinu mēnesis
admincreateinvoices.dialog.title=Pārskatīt rēķinus
admincreateinvoices.dialog.position=Rēķins {0} no {1}
admincreateinvoices.dialog.button.previous=Atpakaļ
admincreateinvoices.dialog.button.next=Tālāk
admincreateinvoices.dialog.button.delete=Noņemt no saraksta
admincreateinvoices.dialog.button.send=Sūtīt rēķinus
admincreateinvoices.dialog.notification.removed=Rēķins noņemts no saraksta
admincreateinvoices.mail.subject=Jūsu rēķins {0}
admincreateinvoices.mail.body=Cienījamie klienti,\n\npielikumā ir Jūsu rēķins {0} par norēķinu periodu {1}.\n\nAr cieņu\n{2}
admincreateinvoices.notification.sent=Pa e-pastu nosūtīti rēķini: {0}
admincreateinvoices.notification.sendfailed=Neizdevās nosūtīt rēķinus: {0}

View File

@@ -982,8 +982,8 @@ misc.retry=Spr\u00f3buj ponownie
# Admin Price Table
adminpricetable.title=Cennik
adminpricetable.field.monthly=Miesi\u0119czny pakiet podstawowy
adminpricetable.field.applicense=Licencja aplikacji
adminpricetable.field.revenue=Udzia\u0142 w przychodach
adminpricetable.field.appuserfee=Opłata za użytkownika aplikacji
adminpricetable.field.appuserfee.helper=Naliczana miesięcznie za każdego skonfigurowanego użytkownika aplikacji
adminpricetable.notification.saved=Cennik zosta\u0142 zapisany
adminpricetable.notification.save.error=B\u0142\u0105d podczas zapisywania: {0}
adminpricetable.notification.load.error=B\u0142\u0105d podczas \u0142adowania: {0}
@@ -1043,7 +1043,12 @@ page.title.admin.createinvoices=Utwórz faktury
admincreateinvoices.title=Utwórz faktury
admincreateinvoices.hint=Faktury są płatne na koniec miesiąca. Jeśli klient rozpoczyna w trakcie miesiąca, rozliczane są tylko dni pozostałe do końca miesiąca.
admincreateinvoices.column.customer=Klient
admincreateinvoices.column.email=E-mail
admincreateinvoices.column.discount=Rabat
admincreateinvoices.discount.position=Rabat ({0} %)
admincreateinvoices.discount.dialog.title=Wprowadź rabat
admincreateinvoices.discount.dialog.percent=Rabat w %
admincreateinvoices.discount.dialog.reason=Powód rabatu
admincreateinvoices.discount.dialog.apply=Zastosuj
admincreateinvoices.column.start=Klient od
admincreateinvoices.column.days=Rozliczone dni
admincreateinvoices.column.due=Termin płatności
@@ -1051,8 +1056,20 @@ admincreateinvoices.column.net=Kwota netto
admincreateinvoices.days.full=Pełny miesiąc
admincreateinvoices.days.partial={0} z {1} dni
admincreateinvoices.prorata.suffix=(proporcjonalnie {0}/{1} dni)
admincreateinvoices.appusers.suffix=({0} użytkowników aplikacji)
admincreateinvoices.button.create=Utwórz fakturę
admincreateinvoices.notification.notemplate=Brak szablonu faktury. Najpierw zapisz szablon w generatorze faktur.
admincreateinvoices.notification.error=Błąd podczas tworzenia faktury: {0}
admincreateinvoices.button.createselected=Utwórz faktury ({0})
admincreateinvoices.field.month=Miesiąc rozliczeniowy
admincreateinvoices.dialog.title=Przegląd faktur
admincreateinvoices.dialog.position=Faktura {0} z {1}
admincreateinvoices.dialog.button.previous=Wstecz
admincreateinvoices.dialog.button.next=Dalej
admincreateinvoices.dialog.button.delete=Usuń z listy
admincreateinvoices.dialog.button.send=Wyślij faktury
admincreateinvoices.dialog.notification.removed=Faktura usunięta z listy
admincreateinvoices.mail.subject=Państwa faktura {0}
admincreateinvoices.mail.body=Szanowni Państwo,\n\nw załączeniu przesyłamy fakturę {0} za okres rozliczeniowy {1}.\n\nZ poważaniem\n{2}
admincreateinvoices.notification.sent=Wysłano e-mailem faktur: {0}
admincreateinvoices.notification.sendfailed=Nie udało się wysłać faktur: {0}

View File

@@ -982,8 +982,8 @@ misc.retry=Повторить попытку
# Admin Price Table
adminpricetable.title=Таблица цен
adminpricetable.field.monthly=Ежемесячный базовый пакет
adminpricetable.field.applicense=Лицензия на приложение
adminpricetable.field.revenue=Участие в выручке
adminpricetable.field.appuserfee=Плата за пользователя приложения
adminpricetable.field.appuserfee.helper=Начисляется ежемесячно за каждого настроенного пользователя приложения
adminpricetable.notification.saved=Таблица цен сохранена
adminpricetable.notification.save.error=Ошибка при сохранении: {0}
adminpricetable.notification.load.error=Ошибка при загрузке: {0}
@@ -1043,7 +1043,12 @@ page.title.admin.createinvoices=Создать счета
admincreateinvoices.title=Создать счета
admincreateinvoices.hint=Счета подлежат оплате в конце месяца. Если клиент начинает в середине месяца, оплачиваются только оставшиеся дни до конца месяца.
admincreateinvoices.column.customer=Клиент
admincreateinvoices.column.email=Эл. почта
admincreateinvoices.column.discount=Скидка
admincreateinvoices.discount.position=Скидка ({0} %)
admincreateinvoices.discount.dialog.title=Указать скидку
admincreateinvoices.discount.dialog.percent=Скидка в %
admincreateinvoices.discount.dialog.reason=Причина скидки
admincreateinvoices.discount.dialog.apply=Применить
admincreateinvoices.column.start=Клиент с
admincreateinvoices.column.days=Расчетные дни
admincreateinvoices.column.due=Срок оплаты
@@ -1051,8 +1056,20 @@ admincreateinvoices.column.net=Сумма нетто
admincreateinvoices.days.full=Полный месяц
admincreateinvoices.days.partial={0} из {1} дней
admincreateinvoices.prorata.suffix=(пропорционально {0}/{1} дней)
admincreateinvoices.appusers.suffix=({0} пользователей приложения)
admincreateinvoices.button.create=Создать счет
admincreateinvoices.notification.notemplate=Шаблон счета отсутствует. Сначала сохраните шаблон в генераторе счетов.
admincreateinvoices.notification.error=Ошибка при создании счета: {0}
admincreateinvoices.button.createselected=Создать счета ({0})
admincreateinvoices.field.month=Расчетный месяц
admincreateinvoices.dialog.title=Проверка счетов
admincreateinvoices.dialog.position=Счет {0} из {1}
admincreateinvoices.dialog.button.previous=Назад
admincreateinvoices.dialog.button.next=Далее
admincreateinvoices.dialog.button.delete=Удалить из списка
admincreateinvoices.dialog.button.send=Отправить счета
admincreateinvoices.dialog.notification.removed=Счет удален из списка
admincreateinvoices.mail.subject=Ваш счет {0}
admincreateinvoices.mail.body=Уважаемые дамы и господа!\n\nВо вложении Вы найдете счет {0} за расчетный период {1}.\n\nС уважением\n{2}
admincreateinvoices.notification.sent=Отправлено счетов по эл. почте: {0}
admincreateinvoices.notification.sendfailed=Не удалось отправить счетов: {0}

View File

@@ -982,8 +982,8 @@ misc.retry=Tekrar Dene
# Admin Price Table
adminpricetable.title=Fiyat Tablosu
adminpricetable.field.monthly=Ayl\u0131k Temel Paket
adminpricetable.field.applicense=Uygulama Kullan\u0131m Lisans\u0131
adminpricetable.field.revenue=Gelir Pay\u0131
adminpricetable.field.appuserfee=Uygulama kullanıcısı başına ücret
adminpricetable.field.appuserfee.helper=Kurulan her uygulama kullanıcısı için aylık olarak faturalandırılır
adminpricetable.notification.saved=Fiyat tablosu kaydedildi
adminpricetable.notification.save.error=Kaydetme hatas\u0131: {0}
adminpricetable.notification.load.error=Y\u00fckleme hatas\u0131: {0}
@@ -1043,7 +1043,12 @@ page.title.admin.createinvoices=Fatura oluştur
admincreateinvoices.title=Fatura oluştur
admincreateinvoices.hint=Faturalar ay sonunda ödenir. Bir müşteri ay ortasında başlarsa, yalnızca ay sonuna kadar kalan günler faturalandırılır.
admincreateinvoices.column.customer=Müşteri
admincreateinvoices.column.email=E-posta
admincreateinvoices.column.discount=İndirim
admincreateinvoices.discount.position=İndirim (%{0})
admincreateinvoices.discount.dialog.title=İndirim gir
admincreateinvoices.discount.dialog.percent=İndirim %
admincreateinvoices.discount.dialog.reason=İndirim nedeni
admincreateinvoices.discount.dialog.apply=Uygula
admincreateinvoices.column.start=Müşteri başlangıcı
admincreateinvoices.column.days=Faturalanan günler
admincreateinvoices.column.due=Vade tarihi
@@ -1051,8 +1056,20 @@ admincreateinvoices.column.net=Net tutar
admincreateinvoices.days.full=Tam ay
admincreateinvoices.days.partial={0} / {1} gün
admincreateinvoices.prorata.suffix=(orantılı {0}/{1} gün)
admincreateinvoices.appusers.suffix=({0} uygulama kullanıcısı)
admincreateinvoices.button.create=Fatura oluştur
admincreateinvoices.notification.notemplate=Fatura şablonu yok. Lütfen önce fatura oluşturucuda bir şablon kaydedin.
admincreateinvoices.notification.error=Fatura oluşturulurken hata: {0}
admincreateinvoices.button.createselected=Fatura oluştur ({0})
admincreateinvoices.field.month=Fatura ayı
admincreateinvoices.dialog.title=Faturaları incele
admincreateinvoices.dialog.position=Fatura {0} / {1}
admincreateinvoices.dialog.button.previous=Geri
admincreateinvoices.dialog.button.next=İleri
admincreateinvoices.dialog.button.delete=Listeden kaldır
admincreateinvoices.dialog.button.send=Faturaları gönder
admincreateinvoices.dialog.notification.removed=Fatura listeden kaldırıldı
admincreateinvoices.mail.subject=Faturanız {0}
admincreateinvoices.mail.body=Sayın yetkili,\n\n{1} fatura dönemine ait {0} numaralı faturanız ektedir.\n\nSaygılarımızla\n{2}
admincreateinvoices.notification.sent={0} fatura e-posta ile gönderildi
admincreateinvoices.notification.sendfailed={0} fatura gönderilemedi