Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,11 @@ open class UsageModel(
val credits: SumUsageItemModel?,
val keys: AverageProportionalUsageItemModel = AverageProportionalUsageItemModel(),
val total: BigDecimal = 0.toBigDecimal(),
@Schema(
description =
"Relevant for invoices only. " +
"Total amount deferred from previous billing periods that is included in this invoice.",
)
val carryOverTotal: BigDecimal? = null,
) : RepresentationModel<UsageModel>(),
Serializable
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package io.tolgee.dtos.response

import java.math.BigDecimal

class PublicBillingConfigurationDTO(
val enabled: Boolean,
val minUsageInvoiceAmount: BigDecimal? = null,
)
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class UsageModelAssembler : RepresentationModelAssembler<UsageData, UsageModel>
credits = data.creditsUsage?.let { sumToModel(it) },
total = data.total,
appliedStripeCredits = data.appliedStripeCredits,
carryOverTotal = data.carryOverTotal,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ data class UsageData(
val creditsUsage: SumUsageItem?,
val subscriptionPrice: BigDecimal?,
val appliedStripeCredits: BigDecimal?,
val carryOverTotal: BigDecimal? = null,
) {
val total: BigDecimal
get() =
seatsUsage.sumOf { it.total } +
translationsUsage.sumOf { it.total } +
keysUsage.sumOf { it.total } +
(subscriptionPrice ?: 0.toBigDecimal()) +
(creditsUsage?.total ?: 0.toBigDecimal())
(creditsUsage?.total ?: 0.toBigDecimal()) +
(carryOverTotal ?: 0.toBigDecimal())
}
5 changes: 5 additions & 0 deletions webapp/src/constants/links.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,11 @@ export class LINKS {
'subscriptions'
);

static ADMINISTRATION_BILLING_INVOICES = Link.ofParent(
LINKS.ADMINISTRATION,
'invoices'
);

static ADMINISTRATION_BILLING_EE_PLAN_EDIT = Link.ofParent(
LINKS.ADMINISTRATION_BILLING_EE_PLANS,
p(PARAMS.PLAN_ID)
Expand Down
55 changes: 12 additions & 43 deletions webapp/src/ee/billing/Invoices/DownloadButton.tsx
Original file line number Diff line number Diff line change
@@ -1,68 +1,37 @@
import { FC } from 'react';

import { useOrganization } from 'tg.views/organizations/useOrganization';
import LoadingButton from 'tg.component/common/form/LoadingButton';
import { components } from 'tg.service/billingApiSchema.generated';
import { useConfig } from 'tg.globalContext/helpers';
import { useBillingApiMutation } from 'tg.service/http/useQueryApi';
import { useInvoicePdfDownload } from './useInvoicePdfDownload';
import { PdfDownloadButton } from './PdfDownloadButton';

type DownloadButtonProps = {
invoice: components['schemas']['InvoiceModel'];
};

export const DownloadButton: FC<DownloadButtonProps> = (props) => {
export const DownloadButton: FC<DownloadButtonProps> = ({ invoice }) => {
const organization = useOrganization();
const config = useConfig();
const { onSuccess } = useInvoicePdfDownload(invoice);

const pdfMutation = useBillingApiMutation({
url: '/v2/organizations/{organizationId}/billing/invoices/{invoiceId}/pdf',
method: 'get',
fetchOptions: {
rawResponse: true,
},
fetchOptions: { rawResponse: true },
});

const onDownload = () => {
pdfMutation.mutate(
{
path: {
organizationId: organization!.id,
invoiceId: props.invoice.id,
},
},
{
async onSuccess(response) {
const res = response as unknown as Response;
const data = await res.blob();
const url = URL.createObjectURL(data as any as Blob);
try {
const a = document.createElement('a');
try {
a.href = url;
a.download = `${config.appName.toLowerCase()}-${
props.invoice.number
}.pdf`;

a.click();
} finally {
a.remove();
}
} finally {
setTimeout(() => URL.revokeObjectURL(url), 7000);
}
},
}
{ path: { organizationId: organization!.id, invoiceId: invoice.id } },
{ onSuccess }
);
};

return (
<LoadingButton
disabled={!props.invoice.pdfReady}
loading={pdfMutation.isLoading}
onClick={onDownload}
size="small"
>
PDF
</LoadingButton>
<PdfDownloadButton
invoice={invoice}
onDownload={onDownload}
isLoading={pdfMutation.isLoading}
/>
);
};
1 change: 1 addition & 0 deletions webapp/src/ee/billing/Invoices/InvoiceUsage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export const InvoiceUsage: FC<{
usageData={usage.data}
invoiceId={invoice.id}
invoiceNumber={invoice.number}
organizationId={organization?.id}
></UsageTable>
<TotalTable
invoice={invoice}
Expand Down
27 changes: 27 additions & 0 deletions webapp/src/ee/billing/Invoices/PdfDownloadButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { FC } from 'react';

import LoadingButton from 'tg.component/common/form/LoadingButton';
import { components } from 'tg.service/billingApiSchema.generated';

type PdfDownloadButtonProps = {
invoice: components['schemas']['InvoiceModel'];
onDownload: () => void;
isLoading: boolean;
};

export const PdfDownloadButton: FC<PdfDownloadButtonProps> = ({
invoice,
onDownload,
isLoading,
}) => {
return (
<LoadingButton
disabled={!invoice.pdfReady}
loading={isLoading}
onClick={onDownload}
size="small"
>
PDF
</LoadingButton>
);
};
28 changes: 28 additions & 0 deletions webapp/src/ee/billing/Invoices/useInvoicePdfDownload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { useConfig } from 'tg.globalContext/helpers';
import { components } from 'tg.service/billingApiSchema.generated';

type InvoiceModel = components['schemas']['InvoiceModel'];

export function useInvoicePdfDownload(invoice: InvoiceModel) {
const config = useConfig();

const onSuccess = async (response: unknown) => {
const res = response as Response;
const data = await res.blob();
const url = URL.createObjectURL(data);
try {
const a = document.createElement('a');
try {
a.href = url;
a.download = `${config.appName.toLowerCase()}-${invoice.number}.pdf`;
a.click();
} finally {
a.remove();
}
} finally {
setTimeout(() => URL.revokeObjectURL(url), 7000);
}
};

return { onSuccess };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { FC } from 'react';

import { components } from 'tg.service/billingApiSchema.generated';
import { useBillingApiMutation } from 'tg.service/http/useQueryApi';
import { useInvoicePdfDownload } from '../../Invoices/useInvoicePdfDownload';
import { PdfDownloadButton } from '../../Invoices/PdfDownloadButton';

type AdminDownloadButtonProps = {
invoice: components['schemas']['InvoiceModel'];
};

export const AdminDownloadButton: FC<AdminDownloadButtonProps> = ({
invoice,
}) => {
const { onSuccess } = useInvoicePdfDownload(invoice);

const pdfMutation = useBillingApiMutation({
url: '/v2/administration/billing/invoices/{invoiceId}/pdf',
method: 'get',
fetchOptions: { rawResponse: true },
});

const onDownload = () => {
pdfMutation.mutate({ path: { invoiceId: invoice.id } }, { onSuccess });
};

return (
<PdfDownloadButton
invoice={invoice}
onDownload={onDownload}
isLoading={pdfMutation.isLoading}
/>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import {
Box,
DialogContent,
DialogTitle,
IconButton,
Tooltip,
} from '@mui/material';
import { PieChart01 } from '@untitled-ui/icons-react';
import { FC, useState } from 'react';
import { components } from 'tg.service/billingApiSchema.generated';
import { useTranslate } from '@tolgee/react';
import { useBillingApiQuery } from 'tg.service/http/useQueryApi';
import Dialog from '@mui/material/Dialog';
import { EmptyListMessage } from 'tg.component/common/EmptyListMessage';
import { TotalTable } from '../../common/usage/TotalTable';
import { UsageTable } from '../../common/usage/UsageTable';

export const AdminInvoiceUsage: FC<{
invoice: components['schemas']['InvoiceModel'];
}> = ({ invoice }) => {
const { t } = useTranslate();
const [open, setOpen] = useState(false);

const usage = useBillingApiQuery({
url: '/v2/administration/billing/invoices/{invoiceId}/usage',
method: 'get',
path: {
invoiceId: invoice.id,
},
options: {
enabled: open,
},
});

return (
<>
{invoice.hasUsage && (
<>
<Box>
<IconButton
size="small"
onClick={() => setOpen(true)}
aria-label={t('billing_invoices_show_usage_button')}
>
<Tooltip title={t('billing_invoices_show_usage_button')}>
<PieChart01 />
</Tooltip>
</IconButton>
</Box>
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="md">
<DialogTitle>{t('invoice_usage_dialog_title')}</DialogTitle>
<DialogContent>
{usage.data ? (
<>
<UsageTable
usageData={usage.data}
invoiceId={invoice.id}
invoiceNumber={invoice.number}
organizationId={invoice.organizationId}
/>
<TotalTable
invoice={invoice}
totalWithoutVat={usage.data.total}
appliedStripeCredits={usage.data.appliedStripeCredits}
/>
</>
) : (
<EmptyListMessage loading={usage.isLoading} />
)}
</DialogContent>
</Dialog>
</>
)}
</>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { styled } from '@mui/material';
import { useTranslate } from '@tolgee/react';

import { DashboardPage } from 'tg.component/layout/DashboardPage';
import { LINKS } from 'tg.constants/links';
import { BaseAdministrationView } from 'tg.views/administration/components/BaseAdministrationView';
import { CarryOversSection } from './CarryOversSection';
import { OrgInvoicesSection } from './OrgInvoicesSection';

const StyledWrapper = styled('div')`
display: flex;
flex-direction: column;
gap: 40px;
`;

export const AdministrationInvoicesView = () => {
const { t } = useTranslate();

return (
<DashboardPage>
<BaseAdministrationView
windowTitle={t('administration_invoices', 'Invoices')}
navigation={[
[
t('administration_invoices'),
LINKS.ADMINISTRATION_BILLING_INVOICES.build(),
],
]}
allCentered
hideChildrenOnLoading={false}
>
<StyledWrapper>
<OrgInvoicesSection />
<CarryOversSection />
</StyledWrapper>
</BaseAdministrationView>
</DashboardPage>
);
};
Loading
Loading