diff --git a/aleksis/apps/tezor/frontend/components/invoice/InvoiceActions.vue b/aleksis/apps/tezor/frontend/components/invoice/InvoiceActions.vue new file mode 100644 index 0000000000000000000000000000000000000000..c800da5491dc205e2fdb1a48845fc35a9be42fcc --- /dev/null +++ b/aleksis/apps/tezor/frontend/components/invoice/InvoiceActions.vue @@ -0,0 +1,60 @@ +<script setup> +import SecondaryActionButton from "aleksis.core/components/generic/buttons/SecondaryActionButton.vue"; +</script> +<script> +import { sendInvoiceEmails } from "./invoices.graphql"; +export default { + name: "InvoiceActions", + props: { + invoice: { + type: Object, + required: true, + }, + }, + methods: { + onDone() { + this.$toastSuccess( + this.$t("tezor.invoice.send_email_success", { + number: this.invoice.number, + email: this.invoice.billingEmail, + }), + ); + }, + }, + data() { + return { + gqlSendInvoiceEmails: sendInvoiceEmails, + }; + }, +}; +</script> + +<template> + <div> + <secondary-action-button + v-if="invoice.canPrintInvoice" + i18n-key="tezor.invoice.actions.print" + icon-text="mdi-printer-outline" + :to="{ name: 'tezor.printInvoice', params: { token: invoice.token } }" + target="_blank" + /> + <ApolloMutation + v-if="invoice.canSendInvoiceEmail" + :mutation="gqlSendInvoiceEmails" + :variables="{ ids: [this.invoice.token] }" + @done="onDone" + > + <template #default="{ mutate, loading, error }"> + <secondary-action-button + i18n-key="tezor.invoice.actions.send_email" + icon-text="mdi-email-fast-outline" + :disabled="loading" + :loading="loading" + @click="mutate" + /> + </template> + </ApolloMutation> + </div> +</template> + +<style scoped></style> diff --git a/aleksis/apps/tezor/frontend/components/invoice/InvoiceOverview.vue b/aleksis/apps/tezor/frontend/components/invoice/InvoiceOverview.vue new file mode 100644 index 0000000000000000000000000000000000000000..2c21784c6000696c3b656368cd1f2413be225bdd --- /dev/null +++ b/aleksis/apps/tezor/frontend/components/invoice/InvoiceOverview.vue @@ -0,0 +1,80 @@ +<script setup> +import ObjectOverview from "aleksis.core/components/generic/ObjectOverview.vue"; +import InvoiceRecipientCard from "./InvoiceRecipientCard.vue"; +import InvoiceActions from "./InvoiceActions.vue"; +import PurchasedItemsTable from "./PurchasedItemsTable.vue"; +import PaymentStatusCard from "./PaymentStatusCard.vue"; +</script> +<script> +import { invoiceByToken } from "./invoices.graphql"; + +export default { + name: "InvoiceOverview", + data() { + return { + query: invoiceByToken, + }; + }, + props: { + id: { + type: String, + required: false, + default: null, + }, + }, +}; +</script> + +<template> + <div> + <object-overview :query="query" title-attr="number" :id="id"> + <template #loading> + <v-skeleton-loader type="article" /> + + <v-row> + <v-col cols="12" lg="4" v-for="idx in 3" :key="idx"> + <v-skeleton-loader type="card" /> + </v-col> + </v-row> + </template> + + <template #default="invoice"> + <detail-view no-avatar> + <template #title> + {{ + $t("tezor.invoice.title_with_number", { number: invoice.number }) + }} + </template> + + <template #subtitle> + {{ + $t("tezor.invoice.invoice_date", { + date: $d($parseISODate(invoice.createdDate)), + }) + }} + </template> + + <template #actions="{ classes }"> + <invoice-actions :invoice="invoice" :class="classes" /> + </template> + + <v-row> + <v-col cols="12" md="6"> + <invoice-recipient-card :invoice="invoice" /> + </v-col> + <v-col cols="12" md="6"> + <payment-status-card :invoice="invoice" /> + </v-col> + </v-row> + <v-row> + <v-col cols="12"> + <purchased-items-table :invoice="invoice" /> + </v-col> + </v-row> + </detail-view> + </template> + </object-overview> + </div> +</template> + +<style scoped></style> diff --git a/aleksis/apps/tezor/frontend/components/invoice/InvoiceRecipientCard.vue b/aleksis/apps/tezor/frontend/components/invoice/InvoiceRecipientCard.vue new file mode 100644 index 0000000000000000000000000000000000000000..809013400bc2e8468cd79cd47e720556c154890b --- /dev/null +++ b/aleksis/apps/tezor/frontend/components/invoice/InvoiceRecipientCard.vue @@ -0,0 +1,93 @@ +<script setup></script> +<script> +export default { + name: "InvoiceRecipientCard", + props: { + invoice: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <v-card> + <v-card-title>{{ $t("tezor.invoice.recipient.title") }}</v-card-title> + + <v-list two-line> + <v-list-item> + <v-list-item-icon> + <v-icon>mdi-account-outline</v-icon> + </v-list-item-icon> + + <v-list-item-content> + <v-list-item-title> + {{ invoice.billingFirstName }} {{ invoice.billingLastName }} + </v-list-item-title> + <v-list-item-subtitle> + {{ $t("tezor.invoice.recipient.name") }} + </v-list-item-subtitle> + </v-list-item-content> + </v-list-item> + <v-divider inset /> + <v-list-item> + <v-list-item-icon> + <v-icon>mdi-map-marker-outline</v-icon> + </v-list-item-icon> + + <v-list-item-content> + <v-list-item-title> + {{ invoice.billingAddress1 }} <br /> + {{ invoice.billingAddress2 }} <br /> + {{ invoice.billingPostcode }} {{ invoice.billingCity }} <br /> + {{ invoice.billingCountryCode }} + </v-list-item-title> + <v-list-item-subtitle> + {{ $t("tezor.invoice.recipient.address") }} + </v-list-item-subtitle> + </v-list-item-content> + </v-list-item> + + <v-divider inset /> + + <v-list-item + :href="invoice.billingPhone ? 'tel:' + invoice.billingPhone : ''" + > + <v-list-item-icon> + <v-icon> mdi-phone-outline</v-icon> + </v-list-item-icon> + + <v-list-item-content> + <v-list-item-title> + {{ invoice.billingPhone || "–" }} + </v-list-item-title> + <v-list-item-subtitle> + {{ $t("tezor.invoice.recipient.phone") }} + </v-list-item-subtitle> + </v-list-item-content> + </v-list-item> + + <v-divider inset /> + + <v-list-item + :href="invoice.billingEmail ? 'mailto:' + invoice.billingEmail : ''" + > + <v-list-item-icon> + <v-icon>mdi-email-outline</v-icon> + </v-list-item-icon> + + <v-list-item-content> + <v-list-item-title> + {{ invoice.billingEmail || "–" }} + </v-list-item-title> + <v-list-item-subtitle> + {{ $t("tezor.invoice.recipient.email") }} + </v-list-item-subtitle> + </v-list-item-content> + </v-list-item> + </v-list> + </v-card> +</template> + +<style scoped></style> diff --git a/aleksis/apps/tezor/frontend/components/invoice/InvoiceStatusChip.vue b/aleksis/apps/tezor/frontend/components/invoice/InvoiceStatusChip.vue new file mode 100644 index 0000000000000000000000000000000000000000..ad1be61e6afde5d4c03a6bd0e8b88473c41e5c8f --- /dev/null +++ b/aleksis/apps/tezor/frontend/components/invoice/InvoiceStatusChip.vue @@ -0,0 +1,53 @@ +<script> +export default { + name: "InvoiceStatusChip", + props: { + invoice: { + type: Object, + required: true, + }, + }, + data() { + return { + icons: { + waiting: "mdi-cash-lock-open", + preauth: "mdi-cash-lock", + confirmed: "mdi-cash-check", + rejected: "mdi-cash-remove", + refunded: "mdi-cash-refund", + error: "mdi-cash-remove", + input: "mdi-cash-lock-open", + }, + colors: { + waiting: "secondary", + preauth: "secondary", + confirmed: "success", + rejected: "danger", + refunded: "warning", + error: "danger", + input: "secondary", + }, + }; + }, + computed: { + icon() { + return this.icons[this.status]; + }, + color() { + return this.colors[this.status]; + }, + status() { + return this.invoice.status.toLowerCase(); + }, + }, +}; +</script> + +<template> + <v-chip v-bind="$attrs" :color="color"> + <v-icon left>{{ icon }}</v-icon> + {{ $t(`tezor.invoice.status.${status}`) }} + </v-chip> +</template> + +<style scoped></style> diff --git a/aleksis/apps/tezor/frontend/components/invoice/PaymentStatusCard.vue b/aleksis/apps/tezor/frontend/components/invoice/PaymentStatusCard.vue new file mode 100644 index 0000000000000000000000000000000000000000..519d5cd63c4bf06b8bd9ae1b11fe9956b903576f --- /dev/null +++ b/aleksis/apps/tezor/frontend/components/invoice/PaymentStatusCard.vue @@ -0,0 +1,127 @@ +<script setup> +import PaymentVariantChip from "./PaymentVariantChip.vue"; +import InvoiceStatusChip from "./InvoiceStatusChip.vue"; +</script> +<script> +import formatMixin from "../../formatMixin"; +import { DateTime } from "luxon"; +export default { + name: "PaymentStatusCard", + mixins: [formatMixin], + props: { + invoice: { + type: Object, + required: true, + }, + }, + computed: { + dueDatePast() { + const dueDate = this.$parseISODate(this.invoice.dueDate); + return this.invoice.remainingAmount > 0 && dueDate < DateTime.now(); + }, + remainingAmountColor() { + if (this.invoice.remainingAmount > 0) { + return "red--text"; + } else { + return "green--text"; + } + }, + }, +}; +</script> + +<template> + <v-card> + <v-card-title class="d-flex justify-space-between"> + {{ $t("tezor.invoice.payment_status.title") }} + <invoice-status-chip :invoice="invoice" /> + </v-card-title> + + <v-list two-line> + <v-list-item> + <v-list-item-icon> + <v-icon>mdi-calendar-alert-outline</v-icon> + </v-list-item-icon> + + <v-list-item-content> + <v-list-item-title :class="{ 'red--text': dueDatePast }"> + {{ $d($parseISODate(invoice.dueDate)) }} + </v-list-item-title> + <v-list-item-subtitle> + {{ $t("tezor.invoice.payment_status.due_date") }} + </v-list-item-subtitle> + </v-list-item-content> + </v-list-item> + <v-divider /> + <v-list-item> + <v-list-item-icon> + <v-icon>mdi-hand-coin-outline</v-icon> + </v-list-item-icon> + + <v-list-item-content> + <v-list-item-title> + <payment-variant-chip :variant="invoice.variant" size="small" /> + </v-list-item-title> + </v-list-item-content> + </v-list-item> + + <v-list-item> + <v-list-item-icon> + <v-icon>mdi-credit-card-check-outline</v-icon> + </v-list-item-icon> + + <v-list-item-content> + <v-list-item-title> + <span class="float-right text-h6">{{ + formatCurrency(invoice.capturedAmount, invoice.currency) + }}</span> + {{ $t("tezor.invoice.payment_status.captured_amount") }}: + </v-list-item-title> + </v-list-item-content> + </v-list-item> + <v-list-item> + <v-list-item-icon> + <v-icon>mdi-credit-card-clock-outline</v-icon> + </v-list-item-icon> + + <v-list-item-content> + <v-list-item-title> + <span :class="'float-right text-h6 ' + remainingAmountColor">{{ + formatCurrency(invoice.remainingAmount, invoice.currency) + }}</span> + {{ $t("tezor.invoice.payment_status.remaining_amount") }}: + </v-list-item-title> + </v-list-item-content> + </v-list-item> + <v-divider /> + </v-list> + <v-card-text class="pa-2" v-if="invoice.canDoPayment"> + <v-btn + color="primary" + v-for="variant in invoice.paymentVariants" + :key="variant.name" + class="full-width mb-2" + :to="{ + name: 'tezor.doPayment', + params: { token: invoice.token }, + query: { variant: variant.name }, + }" + > + <v-icon left>{{ variant.icon }}</v-icon> + {{ + $t("tezor.invoice.payment_status.pay_now", { + variant: variant.verboseName, + }) + }} + </v-btn> + </v-card-text> + <v-card-text class="pa-2" v-if="invoice.canMarkAsPaid"> + <v-btn color="primary" class="full-width mb-2"> + <v-icon left>mdi-check-all</v-icon> + {{ $t("tezor.invoice.payment_status.mark_as_paid") }} + </v-btn> + </v-card-text> + </v-card> +</template> + +<style scoped></style> diff --git a/aleksis/apps/tezor/frontend/components/invoice/PaymentVariantChip.vue b/aleksis/apps/tezor/frontend/components/invoice/PaymentVariantChip.vue new file mode 100644 index 0000000000000000000000000000000000000000..cb5ea321483a801229b189651de70a511813eb20 --- /dev/null +++ b/aleksis/apps/tezor/frontend/components/invoice/PaymentVariantChip.vue @@ -0,0 +1,20 @@ +<script> +export default { + name: "PaymentVariantChip", + props: { + variant: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <v-chip v-bind="$attrs" color="secondary"> + <v-icon left>{{ variant.icon }}</v-icon> + {{ variant.verboseName }} + </v-chip> +</template> + +<style scoped></style> diff --git a/aleksis/apps/tezor/frontend/components/invoice/PurchasedItemsTable.vue b/aleksis/apps/tezor/frontend/components/invoice/PurchasedItemsTable.vue new file mode 100644 index 0000000000000000000000000000000000000000..7e4b4d10ca2fdefddc7d505e690eed912dd95b06 --- /dev/null +++ b/aleksis/apps/tezor/frontend/components/invoice/PurchasedItemsTable.vue @@ -0,0 +1,65 @@ +<script setup></script> +<script> +import formatMixin from "../../formatMixin"; +export default { + name: "PurchasedItemsTable", + mixins: [formatMixin], + props: { + invoice: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <v-card> + <v-simple-table> + <thead> + <tr> + <th>{{ $t("tezor.invoice.purchased_item.sku") }}</th> + <th>{{ $t("tezor.invoice.purchased_item.item") }}</th> + <th>{{ $t("tezor.invoice.purchased_item.tax_rate") }}</th> + <th>{{ $t("tezor.invoice.purchased_item.quantity") }}</th> + <th>{{ $t("tezor.invoice.purchased_item.unit_price") }}</th> + <th>{{ $t("tezor.invoice.purchased_item.price") }}</th> + </tr> + </thead> + <tbody> + <tr v-for="(item, idx) in invoice.purchasedItems" :key="idx"> + <td>{{ item.sku }}</td> + <td>{{ item.name }}</td> + <td class="text-right">{{ formatPercentage(item.taxRate) }}</td> + <td class="text-right">{{ item.quantity }}</td> + <td class="text-right"> + {{ formatCurrency(item.unitPrice, item.currency) }} + </td> + <td class="text-right"> + {{ formatCurrency(item.price, item.currency) }} + </td> + </tr> + <tr v-for="(tax, idx) in invoice.taxes" :key="idx"> + <td colspan="2"></td> + <td colspan="3"> + {{ $t("tezor.invoice.tax.title", { rate: tax.rate }) }} + </td> + <td class="text-right"> + {{ formatCurrency(tax.value, tax.currency) }} + </td> + </tr> + <tr> + <td colspan="2"></td> + <td colspan="3" class="font-weight-bold"> + {{ $t("tezor.invoice.total") }} + </td> + <td class="font-weight-bold text-right"> + {{ formatCurrency(invoice.total, invoice.currency) }} + </td> + </tr> + </tbody> + </v-simple-table> + </v-card> +</template> + +<style scoped></style> diff --git a/aleksis/apps/tezor/frontend/components/invoice/invoices.graphql b/aleksis/apps/tezor/frontend/components/invoice/invoices.graphql new file mode 100644 index 0000000000000000000000000000000000000000..c6abf30a103290e7f7e6b4c6aa5e29849b652523 --- /dev/null +++ b/aleksis/apps/tezor/frontend/components/invoice/invoices.graphql @@ -0,0 +1,65 @@ +fragment invoiceFragment on InvoiceType { + token + number + dueDate + variant { + id + name + verboseName + icon + } + status + createdDate + currency + total + billingFirstName + billingLastName + billingAddress1 + billingAddress2 + billingCity + billingPostcode + billingCountryCode + billingPhone + billingEmail + capturedAmount + remainingAmount + taxes { + rate + value + currency + } + purchasedItems { + name + quantity + unitPrice + price + currency + sku + taxRate + } + paymentVariants { + id + name + verboseName + icon + } + canDoPayment + canMarkAsPaid + canSendInvoiceEmail + canPrintInvoice + canChangePaymentVariant +} + +query invoiceByToken($id: ID) { + object: invoiceByToken(id: $id) { + ...invoiceFragment + } +} + +mutation sendInvoiceEmails($ids: [ID]!) { + sendInvoiceEmails(ids: $ids) { + invoices { + ...invoiceFragment + } + } +} diff --git a/aleksis/apps/tezor/frontend/formatMixin.js b/aleksis/apps/tezor/frontend/formatMixin.js new file mode 100644 index 0000000000000000000000000000000000000000..c301f60451a034f705cc899c39397c9dbec71cb9 --- /dev/null +++ b/aleksis/apps/tezor/frontend/formatMixin.js @@ -0,0 +1,15 @@ +export default { + methods: { + formatCurrency(value, currency) { + return new Intl.NumberFormat(this.$i18n.locale, { + style: "currency", + currency: currency, + }).format(value); + }, + formatPercentage(value) { + return new Intl.NumberFormat(this.$i18n.locale, { + style: "percent", + }).format(value / 100); + }, + }, +}; diff --git a/aleksis/apps/tezor/frontend/index.js b/aleksis/apps/tezor/frontend/index.js index 0504e305d774c868ae634a92afc5590edb8f7a95..c306597714feff520b45c6eaa36fce775381acdb 100644 --- a/aleksis/apps/tezor/frontend/index.js +++ b/aleksis/apps/tezor/frontend/index.js @@ -160,12 +160,13 @@ export default { }, }, { - path: "invoice/:slug/", - component: () => import("aleksis.core/components/LegacyBaseTemplate.vue"), - props: { - byTheGreatnessOfTheAlmightyAleksolotlISwearIAmWorthyOfUsingTheLegacyBaseTemplate: true, - }, + path: "invoice/:id", + component: () => import("./components/invoice/InvoiceOverview.vue"), + props: true, name: "tezor.invoiceByToken", + meta: { + permission: "tezor.view_own_invoices_list_rule", + }, }, { path: "invoice/:token/send/", diff --git a/aleksis/apps/tezor/frontend/messages/en.json b/aleksis/apps/tezor/frontend/messages/en.json index 73c99ecc3e01be8f2b917f3cd38cab92df300f98..4c2a5ce8ca6235a45e5c21408f2a9d772d0f5e4d 100644 --- a/aleksis/apps/tezor/frontend/messages/en.json +++ b/aleksis/apps/tezor/frontend/messages/en.json @@ -9,6 +9,52 @@ }, "payment_variants": { "menu_title": "Payment Variants" + }, + "invoice": { + "title_with_number": "Invoice {number}", + "invoice_date": "Invoice date: {date}", + "recipient": { + "title": "Invoice Recipient", + "name": "Name", + "address": "Address", + "phone": "Phone Number", + "email": "Email Address" + }, + "purchased_item": { + "sku": "Art. No.", + "item": "Item", + "tax_rate": "Tax Rate", + "quantity": "Qty.", + "unit_price": "Unit Net", + "price": "Net" + }, + "tax": { + "title": "VAT {rate} %" + }, + "total": "Gross Total", + "payment_status": { + "title": "Payment Status", + "due_date": "Due Date", + "captured_amount": "Captured Amount", + "remaining_amount": "Remaining Amount", + "pay_now": "Pay now by {variant}", + "mark_as_paid": "Mark as paid" + }, + "actions": { + "print": "Print", + "send_email": "Send email", + "mark_as_paid": "Mark as paid" + }, + "status": { + "waiting": "Waiting for confirmation", + "preauth": "Pre-authorized", + "confirmed": "Confirmed", + "rejected": "Rejected", + "refunded": "Refunded", + "error": "Error", + "input": "Input" + }, + "send_email_success": "The invoice {number} has been sent to {email}." } } } diff --git a/aleksis/apps/tezor/models/invoice.py b/aleksis/apps/tezor/models/invoice.py index b7f37816d9e7267a276727deccc532340d02fda4..204ec5d9f318de321ab6094c7e6752b92570e7da 100644 --- a/aleksis/apps/tezor/models/invoice.py +++ b/aleksis/apps/tezor/models/invoice.py @@ -1,6 +1,6 @@ from collections.abc import Iterator from decimal import Decimal -from typing import NamedTuple +from typing import Any, NamedTuple from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType @@ -166,13 +166,17 @@ class Invoice(BasePayment, PureDjangoModel): else: return [self.billing_email] + @property + def remaining_amount(self): + return self.total - self.captured_amount + @property def purchased_items_table(self): items = [i._asdict() for i in self.get_purchased_items()] return PurchasedItemsTable(items) @property - def totals_table(self): + def taxes_table(self) -> list[dict[str, Any]]: tax_amounts = {} values = [] if self.for_object or self.items.exists(): @@ -184,10 +188,16 @@ class Invoice(BasePayment, PureDjangoModel): values.append( { "name": _("VAT {} %").format(tax_rate), + "rate": Decimal(tax_rate), "value": total, "currency": self.currency, } ) + return values + + @property + def totals_table(self) -> TotalsTable: + values = self.taxes_table values.append( { @@ -205,6 +215,11 @@ class Invoice(BasePayment, PureDjangoModel): def get_failure_url(self): return self.get_absolute_url() + def send_email(self): + from ..tasks import email_invoice + + return email_invoice.delay(self.token) + class InvoiceItem(ExtensibleModel): sku = models.CharField(max_length=255, verbose_name=_("Article no."), blank=True) diff --git a/aleksis/apps/tezor/models/payment_variant.py b/aleksis/apps/tezor/models/payment_variant.py index 1fb91aaf08c88252db9af64bfa9ecffc76762557..1dd7b753a6312360bdb85f59b5230ae695d358dd 100644 --- a/aleksis/apps/tezor/models/payment_variant.py +++ b/aleksis/apps/tezor/models/payment_variant.py @@ -42,7 +42,7 @@ class PaymentVariant(RegistryObject, ExtensiblePolymorphicModel): class SofortPaymentVariant(PaymentVariant): - icon = "simple-icons:klarna" + icon = "mdi-cash-fast" verbose_name = _("Klarna/Sofort") name = "sofort" @@ -62,7 +62,7 @@ class SofortPaymentVariant(PaymentVariant): class PaypalPaymentVariant(PaymentVariant): - icon = "logos:paypal" + icon = "mdi-wallet-outline" verbose_name = _("PayPal") name = "paypal" @@ -88,7 +88,7 @@ class PaypalPaymentVariant(PaymentVariant): class SEPADirectDebitPaymentVariant(PaymentVariant): - icon = "mdi:bank-transfer" + icon = "mdi-bank-transfer" verbose_name = _("SEPA Direct Debit") name = "sdd" @@ -118,7 +118,7 @@ class SEPADirectDebitPaymentVariant(PaymentVariant): class PledgePaymentVariant(PaymentVariant): - icon = "mdi:hand-coin" + icon = "mdi-hand-coin-outline" verbose_name = _("Payment pledge/Manual payment") name = "pledge" diff --git a/aleksis/apps/tezor/rules.py b/aleksis/apps/tezor/rules.py index 2d60647e990c714b26fb512e2dad67acb45fa96b..6d8fa7a041f4bb6b3efd985067dd840c99e1ee6d 100644 --- a/aleksis/apps/tezor/rules.py +++ b/aleksis/apps/tezor/rules.py @@ -111,22 +111,6 @@ delete_invoice_groups_predicate = has_person & ( ) rules.add_perm("tezor.delete_invoice_groups_rule", delete_invoice_groups_predicate) -# Display invoice billing information -display_billing_predicate = has_person & ( - is_own_invoice - | has_global_perm("tezor.display_billing") - | has_object_perm("tezor.display_billing") -) -rules.add_perm("tezor.display_billing_rule", display_billing_predicate) - -# Display invoice purchased items -display_purchased_items_predicate = has_person & ( - is_own_invoice - | has_global_perm("tezor.display_purchased_items") - | has_object_perm("tezor.display_purchased_items") -) -rules.add_perm("tezor.display_purchased_items_rule", display_purchased_items_predicate) - # Change payment variant change_payment_variant_predicate = ( has_person @@ -140,7 +124,7 @@ change_payment_variant_predicate = ( | has_object_perm("tezor.change_payment_variant") ) ) -rules.add_perm("tezor.change_payment_variant", change_payment_variant_predicate) +rules.add_perm("tezor.change_payment_variant_rule", change_payment_variant_predicate) # Start payment do_payment_predicate = ( @@ -157,7 +141,7 @@ do_payment_predicate = ( | has_object_perm("tezor.do_payment") ) ) -rules.add_perm("tezor.do_payment", do_payment_predicate) +rules.add_perm("tezor.do_payment_rule", do_payment_predicate) # View invoice view_invoice_predicate = ( @@ -168,19 +152,25 @@ view_invoice_predicate = ( ) rules.add_perm("tezor.view_invoice_rule", view_invoice_predicate) -print_invoice_predicate = ( - view_invoice_predicate & display_billing_predicate & display_purchased_items_predicate -) +print_invoice_predicate = view_invoice_predicate rules.add_perm("tezor.print_invoice_rule", print_invoice_predicate) # Send invoice email -send_invoice_email_predicate = ( - has_person & is_own_invoice +send_invoice_email_predicate = has_person & ( + is_own_invoice | has_global_perm("tezor.send_invoice_email") | has_object_perm("tezor.send_invoice_email") ) rules.add_perm("tezor.send_invoice_email_rule", send_invoice_email_predicate) +# Send invoice email +mark_as_paid_predicate = ( + has_person + & is_in_payment_status("preauth") + & (has_global_perm("tezor.mark_as_paid") | has_object_perm("tezor.mark_as_paid")) +) +rules.add_perm("tezor.mark_as_paid_rule", mark_as_paid_predicate) + view_own_invoices_predicate = has_person rules.add_perm("tezor.view_own_invoices_list_rule", view_own_invoices_predicate) diff --git a/aleksis/apps/tezor/schema/__init__.py b/aleksis/apps/tezor/schema/__init__.py index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..14b976d126cd9d044f799cf738d40d850f27c4bc 100644 --- a/aleksis/apps/tezor/schema/__init__.py +++ b/aleksis/apps/tezor/schema/__init__.py @@ -0,0 +1,28 @@ +import graphene + +from aleksis.core.schema import FilterOrderList + +from ..models.invoice import Invoice +from .invoice import InvoiceType, SendInvoiceEmailsMutation + + +class Query(graphene.ObjectType): + my_invoices = FilterOrderList(InvoiceType) + invoice_by_token = graphene.Field(InvoiceType, id=graphene.ID()) + + @staticmethod + def resolve_my_invoices(root, info, **kwargs): + if info.context.user.has_perm("tezor.view_own_invoices_list_rule"): + return Invoice.objects.filter(person=info.context.user.person) + return [] + + @staticmethod + def resolve_invoice_by_token(root, info, id, **kwargs): # noqa: A002 + invoice = Invoice.objects.get(token=id) + if info.context.user.has_perm("tezor.view_invoice_rule", invoice): + return invoice + return None + + +class Mutation(graphene.ObjectType): + send_invoice_emails = SendInvoiceEmailsMutation.Field() diff --git a/aleksis/apps/tezor/schema/client.py b/aleksis/apps/tezor/schema/client.py index 9e29bd825bdfb710a92c0e934bb8eea22378b07c..247f2fbb85894a6291eb3e268d2e20d180208ee5 100644 --- a/aleksis/apps/tezor/schema/client.py +++ b/aleksis/apps/tezor/schema/client.py @@ -10,3 +10,10 @@ class PaymentVariantChoiceType(graphene.ObjectType): def resolve_text(root, info, **kwargs): return root[1] + + +class PaymentVariantType(graphene.ObjectType): + id = graphene.ID() + name = graphene.String() + verbose_name = graphene.String() + icon = graphene.String() diff --git a/aleksis/apps/tezor/schema/invoice.py b/aleksis/apps/tezor/schema/invoice.py new file mode 100644 index 0000000000000000000000000000000000000000..cd7d6bc3028810a23aeaa237bac1b2bc843d429b --- /dev/null +++ b/aleksis/apps/tezor/schema/invoice.py @@ -0,0 +1,132 @@ +from django.core.exceptions import PermissionDenied + +import graphene +from graphene import ObjectType +from graphene_django import DjangoObjectType +from payments import PaymentStatus + +from aleksis.core.schema.base import DjangoFilterMixin + +from ..models.invoice import Invoice +from .client import PaymentVariantType + + +class PurchasedItemType(ObjectType): + name = graphene.String() + quantity = graphene.Int() + unit_price = graphene.Decimal() + price = graphene.Decimal() + currency = graphene.String() + sku = graphene.String() + tax_rate = graphene.Decimal(required=False) + + +class TaxType(ObjectType): + rate = graphene.Decimal() + value = graphene.Decimal() + currency = graphene.String() + + +class InvoiceType(DjangoFilterMixin, DjangoObjectType): + class Meta: + model = Invoice + fields = [ + "token", + "number", + "due_date", + "status", + "currency", + "total", + "billing_first_name", + "billing_last_name", + "billing_address_1", + "billing_address_2", + "billing_city", + "billing_postcode", + "billing_country_code", + "billing_email", + "billing_phone", + "captured_amount", + ] + + created_date = graphene.Date() + remaining_amount = graphene.Decimal() + + variant = graphene.Field(PaymentVariantType) + payment_variants = graphene.List(PaymentVariantType) + + taxes = graphene.List(TaxType) + purchased_items = graphene.List(PurchasedItemType) + + can_do_payment = graphene.Boolean() + can_mark_as_paid = graphene.Boolean() + can_send_invoice_email = graphene.Boolean() + can_print_invoice = graphene.Boolean() + can_change_payment_variant = graphene.Boolean() + + @staticmethod + def resolve_taxes(root, info, **kwargs): + return root.taxes_table + + @staticmethod + def resolve_purchased_items(root, info, **kwargs): + return root.get_purchased_items() + + @staticmethod + def resolve_created_date(root, info, **kwargs): + return root.created.date() + + @staticmethod + def resolve_variant(root, info, **kwargs): + return root.get_variant() + + @staticmethod + def resolve_payment_variants(root, info, **kwargs): + if info.context.user.has_perm("tezor.change_payment_variant_rule", root): + return root.group.client.payment_variants.all() + return [root.get_variant()] + + @staticmethod + def resolve_can_do_payment(root, info, **kwargs): + return info.context.user.has_perm("tezor.do_payment_rule", root) and ( + root.status in [PaymentStatus.WAITING, PaymentStatus.REJECTED, PaymentStatus.INPUT] + ) + + @staticmethod + def resolve_can_mark_as_paid(root, info, **kwargs): + return ( + info.context.user.has_perm("tezor.mark_as_paid_rule", root) + and root.status == PaymentStatus.PREAUTH + ) + + @staticmethod + def resolve_can_send_invoice_email(root, info, **kwargs): + return info.context.user.has_perm("tezor.send_invoice_email_rule", root) + + @staticmethod + def resolve_can_print_invoice(root, info, **kwargs): + return info.context.user.has_perm("tezor.print_invoice_rule", root) + + @staticmethod + def resolve_can_change_payment_variant(root, info, **kwargs): + return info.context.user.has_perm("tezor.change_payment_variant_rule", root) + + +class SendInvoiceEmailsMutation(graphene.Mutation): + class Arguments: + ids = graphene.List(graphene.ID) + + invoices = graphene.List(InvoiceType) + + @classmethod + def mutate(cls, root, info, ids): # noqa + invoices = [] + for id_ in ids: + invoice = Invoice.objects.get(token=id_) + + if not info.context.user.has_perm("tezor.send_invoice_email_rule", invoice): + raise PermissionDenied() + invoice.send_email() + invoices.append(invoice) + + return SendInvoiceEmailsMutation(invoices=invoices) diff --git a/aleksis/apps/tezor/templates/tezor/invoice/full.html b/aleksis/apps/tezor/templates/tezor/invoice/full.html deleted file mode 100644 index 1263c94d83783aa8e8ea7156088713362886947c..0000000000000000000000000000000000000000 --- a/aleksis/apps/tezor/templates/tezor/invoice/full.html +++ /dev/null @@ -1,126 +0,0 @@ -{% extends "core/base.html" %} -{% load material_form i18n rules %} - -{% load render_table from django_tables2 %} - -{% block browser_title %}{{ object.number }}{% endblock %} - -{% block content %} - - {% has_perm 'tezor.do_payment' user object as can_do_payment %} - {% has_perm 'tezor.view_invoice_group_rule' user object.group as can_view_invoice_group %} - {% has_perm 'tezor.display_purchased_items_rule' user object as can_view_purchased_items %} - {% has_perm 'tezor.display_billing_rule' user object as can_view_billing_information %} - {% has_perm 'tezor.print_invoice_rule' user object as can_print_invoice %} - {% has_perm 'tezor.send_invoice_email_rule' user object as can_send_invoice_email %} - {% has_perm 'tezor.change_payment_variant' user object as can_change_variant %} - {% has_perm 'tezor.mark_paid_rule' user object as can_mark_as_paid %} - - <h1>{% trans "Invoice" %} {{ object.number }} — {{ object.created.date }}</h1> - - {% if can_view_invoice_group %} - <a class="btn colour-primary waves-effect waves-light" href="{% url 'invoice_group_by_pk' object.group.pk %}">{% trans "Back" %}</a> - {% endif %} - {% if can_print_invoice %} - <a class="btn colour-primary waves-effect waves-light" href="{% url 'print_invoice' object.token %}">{% trans "Print" %}</a> - {% endif %} - {% if can_send_invoice_email %} - <a class="btn colour-primary waves-effect waves-light" href="{% url 'send_invoice_by_token' object.token %}">{% trans "Send Email" %}</a> - {% endif %} - - <div class="row"> - {% if can_view_billing_information %} - <div class="col s12 m6"> - <div class="card"> - <div class="card-content"> - <span class="card-title">{% trans "Billing information" %}</span> - <table class="highlight"> - <tr> - <td> - <i class="material-icons small iconify" data-icon="mdi:account-outline"></i> - </td> - <td>{{ object.billing_first_name }} {{object.billing_last_name }}</td> - </tr> - <tr> - <td rowspan="2"> - <i class="material-icons small iconify" data-icon="mdi:map-marker-outline"></i> - </td> - <td>{{ object.billing_address_1 }} {{ object.billing_address_2 }}</td> - </tr> - <tr> - <td>{{ object.billing_postcode }} {{ object.billing_city}}</td> - </tr> - <tr> - <td> - <i class="material-icons small iconify" data-icon="mdi:email-outline"></i> - </td> - <td> - <a href="mailto:{{ object.billing_email }}">{{ object.billing_email }}</a> - </td> - </tr> - </table> - </div> - </div> - </div> - {% endif %} - <form action="{% url 'do_payment' object.token %}"> - <div class="col s12 m6"> - <div class="card"> - <div class="card-content"> - <span class="card-title">{% trans "Payment" %}</span> - <table class="highlight"> - <tr> - <td> - <i class="material-icons iconify" data-icon="{{ object.get_variant_icon }}"></i> - </td> - <td> - <select name="variant" {% if not can_change_variant %}disabled{% endif %}> - {% for payment_variant in object.group.client.payment_variants.all %} - <option value="{{ payment_variant.name }}" {% if object.variant == payment_variant.name %}selected{% endif %}>{{ payment_variant.verbose_name }}</option> - {% endfor %} - </select> - </td> - </tr> - <tr> - <td> - <i class="material-icons iconify" data-icon="{{ object.get_status_icon }}"></i> - </td> - <td> - {{ object.get_status_display }} - </td> - </tr> - <tr> - <td> - <i class="material-icons iconify" data-icon="mdi:calendar-end"></i> - </td> - <td> - {{ object.due_date }} - </td> - </tr> - </table> - </div> - {% if object.status == "waiting" or object.status == "rejected" or object.status == "input" and can_do_payment %} - <div class="card-action"> - <button class="btn waves-effect waves-light green" type="submit"> - <i class="material-icons left iconify" data-icon="mdi:cash-fast"></i> - {% trans "Pay now" %} - </button> - </div> - {% endif %} - {% if object.status == "preauth" and can_mark_as_paid %} - <div class="card-action"> - <a class="btn waves-effect waves-light green" href="{% url 'mark_invoice_paid_by_token' object.token %}"> - <i class="material-icons left iconify" data-icon="mdi:check-all"></i> - {% trans "Mark as paid" %} - </a> - </div> - {% endif %} - </div> - </div> - </form> - </div> - - {% render_table object.purchased_items_table %} - {% render_table object.totals_table %} - -{% endblock %} diff --git a/aleksis/apps/tezor/urls.py b/aleksis/apps/tezor/urls.py index c2742f887722a629e73c44b728277a0a2dcb91ca..b26d86e740a61d9130b651cd1378fa27560041a3 100644 --- a/aleksis/apps/tezor/urls.py +++ b/aleksis/apps/tezor/urls.py @@ -1,4 +1,5 @@ from django.urls import include, path +from django.views.generic import TemplateView from . import views @@ -55,7 +56,7 @@ urlpatterns = [ path("invoices/my/", views.MyInvoicesListView.as_view(), name="personal_invoices"), path( "invoice/<str:slug>/", - views.InvoiceDetailView.as_view(), + TemplateView.as_view(template_name="core/vue_index.html"), name="invoice_by_token", ), path( diff --git a/aleksis/apps/tezor/views.py b/aleksis/apps/tezor/views.py index 3fdabfeea09bcb3dc655a869ba9d2321f07ca231..4c78e97e53ef4159b1109aad2ff286a4b62bf01f 100644 --- a/aleksis/apps/tezor/views.py +++ b/aleksis/apps/tezor/views.py @@ -59,7 +59,7 @@ class DoPaymentView(PermissionRequiredMixin, View): new_variant = request.GET.get("variant", None) if new_variant: - if request.user.has_perm("tezor.change_payment_variant", self.object): + if request.user.has_perm("tezor.change_payment_variant_rule", self.object): if new_variant in allowed_variants: self.object.variant = new_variant self.object.save() @@ -306,13 +306,6 @@ class PaymentVariantDeleteView(PermissionRequiredMixin, AdvancedDeleteView): success_message = _("The payment variant has been deleted.") -class InvoiceDetailView(PermissionRequiredMixin, DetailView): - model = Invoice - slug_field = "token" - permission_required = "tezor.view_invoice_rule" - template_name = "tezor/invoice/full.html" - - class SendInvoiceEmail(PermissionRequiredMixin, View): permission_required = "tezor.send_invoice_email_rule"