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"