diff --git a/backend/pom.xml b/backend/pom.xml index 47ff8a6..6ede0f6 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -11,7 +11,7 @@ jar - 0.9.18 + 0.9.20 21 21 21 diff --git a/backend/src/main/java/de/assecutor/votianlt/model/PriceTable.java b/backend/src/main/java/de/assecutor/votianlt/model/PriceTable.java index fdaad0b..b6a461e 100644 --- a/backend/src/main/java/de/assecutor/votianlt/model/PriceTable.java +++ b/backend/src/main/java/de/assecutor/votianlt/model/PriceTable.java @@ -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() { diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/view/AdminCreateInvoicesView.java b/backend/src/main/java/de/assecutor/votianlt/pages/view/AdminCreateInvoicesView.java index 5eebb7b..22b905d 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/view/AdminCreateInvoicesView.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/view/AdminCreateInvoicesView.java @@ -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 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> 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 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 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> positions = new ArrayList<>(info.positions()); + Map 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 infos) { + private void openInvoiceReviewDialog(List infos) { if (infos.isEmpty()) { return; } @@ -286,20 +416,142 @@ public class AdminCreateInvoicesView extends Main implements HasDynamicTitle { if (templateData == null) { return; } + List previews = new ArrayList<>(); try { - List 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 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 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 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 : ""; } diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/view/AdminPricetableView.java b/backend/src/main/java/de/assecutor/votianlt/pages/view/AdminPricetableView.java index a0b12df..d51961a 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/view/AdminPricetableView.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/view/AdminPricetableView.java @@ -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, diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/view/InvoiceGeneratorView.java b/backend/src/main/java/de/assecutor/votianlt/pages/view/InvoiceGeneratorView.java index 8c316e6..5f1fa2c 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/view/InvoiceGeneratorView.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/view/InvoiceGeneratorView.java @@ -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); diff --git a/backend/src/main/java/de/assecutor/votianlt/repository/AppUserRepository.java b/backend/src/main/java/de/assecutor/votianlt/repository/AppUserRepository.java index 6f96862..44c1722 100644 --- a/backend/src/main/java/de/assecutor/votianlt/repository/AppUserRepository.java +++ b/backend/src/main/java/de/assecutor/votianlt/repository/AppUserRepository.java @@ -23,4 +23,8 @@ public interface AppUserRepository extends MongoRepository { // 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); } diff --git a/backend/src/main/java/de/assecutor/votianlt/service/EmailService.java b/backend/src/main/java/de/assecutor/votianlt/service/EmailService.java index 8bbf97f..8472277 100644 --- a/backend/src/main/java/de/assecutor/votianlt/service/EmailService.java +++ b/backend/src/main/java/de/assecutor/votianlt/service/EmailService.java @@ -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 */ diff --git a/backend/src/main/resources/messages_de.properties b/backend/src/main/resources/messages_de.properties index 898f630..8a98427 100644 --- a/backend/src/main/resources/messages_de.properties +++ b/backend/src/main/resources/messages_de.properties @@ -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 diff --git a/backend/src/main/resources/messages_ee.properties b/backend/src/main/resources/messages_ee.properties index 243412c..57dc1c0 100644 --- a/backend/src/main/resources/messages_ee.properties +++ b/backend/src/main/resources/messages_ee.properties @@ -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 diff --git a/backend/src/main/resources/messages_en.properties b/backend/src/main/resources/messages_en.properties index 054a68e..74323c4 100644 --- a/backend/src/main/resources/messages_en.properties +++ b/backend/src/main/resources/messages_en.properties @@ -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 diff --git a/backend/src/main/resources/messages_es.properties b/backend/src/main/resources/messages_es.properties index 5809e58..e12af38 100644 --- a/backend/src/main/resources/messages_es.properties +++ b/backend/src/main/resources/messages_es.properties @@ -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) diff --git a/backend/src/main/resources/messages_fr.properties b/backend/src/main/resources/messages_fr.properties index e67ab06..6648801 100644 --- a/backend/src/main/resources/messages_fr.properties +++ b/backend/src/main/resources/messages_fr.properties @@ -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 diff --git a/backend/src/main/resources/messages_lt.properties b/backend/src/main/resources/messages_lt.properties index c1bd28d..3c28e61 100644 --- a/backend/src/main/resources/messages_lt.properties +++ b/backend/src/main/resources/messages_lt.properties @@ -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} diff --git a/backend/src/main/resources/messages_lv.properties b/backend/src/main/resources/messages_lv.properties index b8aa101..02ff4bd 100644 --- a/backend/src/main/resources/messages_lv.properties +++ b/backend/src/main/resources/messages_lv.properties @@ -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} diff --git a/backend/src/main/resources/messages_pl.properties b/backend/src/main/resources/messages_pl.properties index a32f794..0afb78f 100644 --- a/backend/src/main/resources/messages_pl.properties +++ b/backend/src/main/resources/messages_pl.properties @@ -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} diff --git a/backend/src/main/resources/messages_ru.properties b/backend/src/main/resources/messages_ru.properties index 01a3513..5217644 100644 --- a/backend/src/main/resources/messages_ru.properties +++ b/backend/src/main/resources/messages_ru.properties @@ -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} diff --git a/backend/src/main/resources/messages_tr.properties b/backend/src/main/resources/messages_tr.properties index 86ba0ab..a500bf1 100644 --- a/backend/src/main/resources/messages_tr.properties +++ b/backend/src/main/resources/messages_tr.properties @@ -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