feat(tax): update Subsequent Invoice Endpoint to Use Stripe Tax

Because:

* we want to dispaly tax information on subsequent invoice preview which is used on the subscription management page

This commit:

* updates the preview-subsequent endpoint to include stripe tax when enabled and updates the manage subscription page to use the total from the
subsequent invoice when able to

Closes #FXA-6192
This commit is contained in:
Ivo Plamenac 2022-11-03 10:40:15 -07:00
Родитель fd16bbd818
Коммит c02f255e72
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: D0A925BE741ABF46
9 изменённых файлов: 154 добавлений и 20 удалений

Просмотреть файл

@ -47,9 +47,21 @@ export function stripeInvoiceToFirstInvoicePreviewDTO(
export function stripeInvoicesToSubsequentInvoicePreviewsDTO(
invoices: Stripe.Invoice[]
): invoiceDTO.SubsequentInvoicePreview[] {
return invoices.map((invoice) => ({
subscriptionId: invoice.subscription as string,
period_start: invoice.period_end,
total: invoice.total,
}));
return invoices.map((invoice) => {
const invoicePreview: invoiceDTO.subsequentInvoicePreview = {
subscriptionId: invoice.subscription as string,
period_start: invoice.period_end,
total: invoice.total,
};
if (invoice.total_tax_amounts.length > 0) {
const tax = invoice.total_tax_amounts[0];
invoicePreview.tax = {
amount: tax.amount,
inclusive: tax.inclusive,
};
}
return invoicePreview;
});
}

Просмотреть файл

@ -734,13 +734,32 @@ export class StripeHelper extends StripeHelperBase {
* Previews the subsequent invoice for a specific subscription
*/
async previewInvoiceBySubscriptionId({
automaticTax,
subscriptionId,
}: {
automaticTax: boolean;
subscriptionId: string;
}) {
return this.stripe.invoices.retrieveUpcoming({
subscription: subscriptionId,
});
if (automaticTax) {
try {
return this.stripe.invoices.retrieveUpcoming({
subscription: subscriptionId,
automatic_tax: {
enabled: true,
},
});
} catch (e: any) {
this.log.warn('stripe.previewInvoice.automatic_tax', {
subscriptionId,
});
throw e;
}
} else {
return this.stripe.invoices.retrieveUpcoming({
subscription: subscriptionId,
});
}
}
/** Fetch a coupon with `applies_to` expanded. */

Просмотреть файл

@ -444,6 +444,7 @@ export class StripeHandler {
request: AuthRequest
): Promise<invoiceDTO.subsequentInvoicePreviewsSchema> {
this.log.begin('subscriptions.subsequentInvoicePreview', request);
const automaticTax = this.automaticTax;
const { uid, email } = await handleAuth(this.db, request.auth, true);
await this.customs.check(request, email, 'subsequentInvoicePreviews');
@ -460,6 +461,7 @@ export class StripeHandler {
customer.subscriptions.data.map((sub) => {
if (!sub.canceled_at) {
return this.stripeHelper.previewInvoiceBySubscriptionId({
automaticTax,
subscriptionId: sub.id,
});
} else {

Просмотреть файл

@ -2113,7 +2113,6 @@ describe('StripeHelper', () => {
});
it('logs when there is an error when automatic tax is enabled', async () => {
// const logStub = sandbox.stub(stripeHelper.log, 'warn');
sandbox
.stub(stripeHelper.stripe.invoices, 'retrieveUpcoming')
.throws(new Error());
@ -2131,6 +2130,57 @@ describe('StripeHelper', () => {
});
});
describe('previewInvoiceBySubscriptionId', () => {
it('uses country when automatic tax is not enabled', async () => {
const stripeStub = sandbox
.stub(stripeHelper.stripe.invoices, 'retrieveUpcoming')
.resolves();
sandbox.stub(stripeHelper, 'taxRateByCountryCode').resolves();
await stripeHelper.previewInvoiceBySubscriptionId({
automaticTax: false,
subscriptionId: 'sub123',
});
sinon.assert.calledOnceWithExactly(stripeStub, {
subscription: 'sub123',
});
});
it('uses ipAddress when automatic tax is enabled', async () => {
const stripeStub = sandbox
.stub(stripeHelper.stripe.invoices, 'retrieveUpcoming')
.resolves();
await stripeHelper.previewInvoiceBySubscriptionId({
automaticTax: true,
subscriptionId: 'sub123',
});
sinon.assert.calledOnceWithExactly(stripeStub, {
subscription: 'sub123',
automatic_tax: {
enabled: true,
},
});
});
it('logs when there is an error when automatic tax is enabled', async () => {
sandbox
.stub(stripeHelper.stripe.invoices, 'retrieveUpcoming')
.throws(new Error());
try {
await stripeHelper.previewInvoiceBySubscriptionId({
automaticTax: true,
subscriptionId: 'sub123',
});
} catch (e) {
sinon.assert.calledOnce(stripeHelper.log.warn);
}
});
});
describe('retrievePromotionCodeForPlan', () => {
it('finds a stripe promotionCode object when a valid code is used', async () => {
const promotionCode = { code: 'promo1', coupon: { valid: true } };

Просмотреть файл

@ -715,11 +715,11 @@ describe('DirectStripeRoutes', () => {
);
sinon.assert.calledWith(
directStripeRoutesInstance.stripeHelper.previewInvoiceBySubscriptionId,
{ subscriptionId: 'sub_id1' }
{ automaticTax: false, subscriptionId: 'sub_id1' }
);
sinon.assert.calledWith(
directStripeRoutesInstance.stripeHelper.previewInvoiceBySubscriptionId,
{ subscriptionId: 'sub_id2' }
{ automaticTax: false, subscriptionId: 'sub_id2' }
);
assert.deepEqual(
stripeInvoicesToSubsequentInvoicePreviewsDTO([expected, expected]),
@ -798,6 +798,35 @@ describe('DirectStripeRoutes', () => {
);
assert.deepEqual(expected, actual);
});
it('uses stripe tax if enabled', async () => {
directStripeRoutesInstance.automaticTax = true;
const expected = deepCopy(invoicePreviewTax);
directStripeRoutesInstance.stripeHelper.previewInvoiceBySubscriptionId.resolves(
expected
);
directStripeRoutesInstance.stripeHelper.fetchCustomer.resolves({
id: 'cus_id',
subscriptions: {
data: [{ id: 'sub_id1' }, { id: 'sub_id2' }],
},
});
VALID_REQUEST.app.geo = {};
await directStripeRoutesInstance.subsequentInvoicePreviews(VALID_REQUEST);
sinon.assert.calledTwice(
directStripeRoutesInstance.stripeHelper.previewInvoiceBySubscriptionId
);
sinon.assert.calledWith(
directStripeRoutesInstance.stripeHelper.previewInvoiceBySubscriptionId,
{ automaticTax: true, subscriptionId: 'sub_id1' }
);
sinon.assert.calledWith(
directStripeRoutesInstance.stripeHelper.previewInvoiceBySubscriptionId,
{ automaticTax: true, subscriptionId: 'sub_id2' }
);
});
});
describe('retrieveCouponDetails', () => {

Просмотреть файл

@ -136,6 +136,7 @@ const ConfirmationDialog = ({
customer,
customerSubscription,
periodEndDate,
total,
}: {
onDismiss: Function;
onConfirm: () => void;
@ -143,6 +144,7 @@ const ConfirmationDialog = ({
customer: Customer;
customerSubscription: WebSubscription;
periodEndDate: number;
total?: number;
}) => {
const { navigatorLanguages, config } = useContext(AppContext);
const { webIcon, webIconBackground } = webIconConfigFromProductConfig(
@ -190,7 +192,7 @@ const ConfirmationDialog = ({
periodEndDate={periodEndDate}
currency={plan.currency}
productName={plan.product_name}
amount={amount}
amount={total || amount}
last4={last4}
webIconURL={webIcon}
webIconBackground={webIconBackground}

Просмотреть файл

@ -17,11 +17,13 @@ const ReactivateSubscriptionPanel = ({
customerSubscription,
customer,
reactivateSubscription,
total,
}: {
plan: Plan;
customerSubscription: WebSubscription;
customer: Customer;
reactivateSubscription: ActionFunctions['reactivateSubscription'];
total?: number;
}) => {
const { subscription_id } = customerSubscription;
const [
@ -51,6 +53,7 @@ const ReactivateSubscriptionPanel = ({
customer,
periodEndDate: periodEndTimeStamp,
customerSubscription,
total,
}}
onDismiss={hideReactivateConfirmation}
onConfirm={onReactivateClick}

Просмотреть файл

@ -51,8 +51,8 @@ export const SubscriptionItem = ({
const labelId = 'subscription-' + plan?.product_id;
if (!plan) {
const ariaLabelledBy = "error-product-plan-not-found-header";
const ariaDescribedBy = "error-product-plan-not-found-description";
const ariaLabelledBy = 'error-product-plan-not-found-header';
const ariaDescribedBy = 'error-product-plan-not-found-description';
// TODO: This really shouldn't happen, would mean the user has a
// subscription to a plan that no longer exists in API results.
return (
@ -63,7 +63,9 @@ export const SubscriptionItem = ({
descId={ariaDescribedBy}
>
<Localized id="product-plan-not-found">
<h4 id={ariaLabelledBy} data-testid="error-subhub-missing-plan">Plan not found</h4>
<h4 id={ariaLabelledBy} data-testid="error-subhub-missing-plan">
Plan not found
</h4>
</Localized>
<Localized id="sub-item-no-such-plan">
<p id={ariaDescribedBy}>No such plan for this subscription.</p>
@ -76,8 +78,8 @@ export const SubscriptionItem = ({
customerSubscription.cancel_at_period_end === false &&
!((total || total === 0) && period_start)
) {
const ariaLabelledBy = "invoice-not-found-header";
const ariaDescribedBy = "invoice-not-found-description";
const ariaLabelledBy = 'invoice-not-found-header';
const ariaDescribedBy = 'invoice-not-found-description';
return (
<DialogMessage
className="dialog-error"
@ -86,12 +88,17 @@ export const SubscriptionItem = ({
descId={ariaDescribedBy}
>
<Localized id="invoice-not-found">
<h4 id={ariaLabelledBy} data-testid="error-subhub-missing-subsequent-invoice">
<h4
id={ariaLabelledBy}
data-testid="error-subhub-missing-subsequent-invoice"
>
Subsequent invoice not found
</h4>
</Localized>
<Localized id="sub-item-no-such-subsequent-invoice">
<p id={ariaDescribedBy}>Subsequent invoice not found for this subscription.</p>
<p id={ariaDescribedBy}>
Subsequent invoice not found for this subscription.
</p>
</Localized>
</DialogMessage>
);
@ -127,6 +134,7 @@ export const SubscriptionItem = ({
customer,
customerSubscription,
reactivateSubscription,
total: total || plan.amount || undefined,
}}
/>
</>

Просмотреть файл

@ -90,6 +90,7 @@ export interface SubsequentInvoicePreview {
subscriptionId: string;
period_start: number;
total: number;
tax?: InvoiceTax;
}
export const subsequentInvoicePreviewsSchema = joi.array().items(
@ -97,13 +98,21 @@ export const subsequentInvoicePreviewsSchema = joi.array().items(
subscriptionId: joi.string().required(),
period_start: joi.number().required(),
total: joi.number().required(),
tax: joi.object({
amount: joi.number().required(),
inclusive: joi.boolean().required(),
}),
})
);
type subsequentInvoicePreview = {
export type subsequentInvoicePreview = {
subscriptionId: string;
period_start: number;
total: number;
tax?: {
amount: number;
inclusive: boolean;
};
};
export type subsequentInvoicePreviewsSchema = Array<subsequentInvoicePreview>;