зеркало из https://github.com/mozilla/fxa.git
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:
Родитель
fd16bbd818
Коммит
c02f255e72
|
@ -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>;
|
||||
|
|
Загрузка…
Ссылка в новой задаче