feat(auth): add coupon validation

Because:

* We want to know if coupons are created with product
  requirements as we enforce them differently.

This commit:

* Reports a product restriction on a coupon to Sentry if one is
  created in Stripe.

Closes #11427
This commit is contained in:
Ben Bangert 2022-01-06 12:02:36 -08:00
Родитель 4733484799
Коммит 28ed143af1
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 340D6D716D25CCA6
5 изменённых файлов: 112 добавлений и 0 удалений

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

@ -580,6 +580,13 @@ export class StripeHelper {
});
}
/** Fetch a coupon with `applies_to` expanded. */
async getCoupon(couponId: string) {
return this.stripe.coupons.retrieve(couponId, {
expand: ['applies_to'],
});
}
/**
* Determines whether a given promotion code is
* a valid code in the system for the given price, and if it hasn't

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

@ -75,6 +75,10 @@ export class StripeWebhookHandler extends StripeHandler {
await this.handleCreditNoteEvent(request, event);
}
break;
case 'coupon.created':
case 'coupon.updated':
await this.handleCouponEvent(request, event);
break;
case 'customer.created':
// We don't need to setup the local customer if it happened via API
// because we already set this up during creation.
@ -212,6 +216,25 @@ export class StripeWebhookHandler extends StripeHandler {
return;
}
/**
* Handle `coupon.created` and `coupon.updated` Stripe webhook events.
*
* Verify that the coupon confirms to our requirements, currently that it:
* - Does not have a product ID requirement.
*/
async handleCouponEvent(request: AuthRequest, event: Stripe.Event) {
const eventCoupon = event.data.object as Stripe.Coupon;
const coupon = await this.stripeHelper.getCoupon(eventCoupon.id);
if (coupon.applies_to?.products && coupon.applies_to?.products.length > 0) {
reportSentryError(
new Error(`Coupon has a product requirement: ${coupon.id}.`),
request
);
return;
}
}
/**
* Handle `customer.created` Stripe webhook events.
*/

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

@ -0,0 +1,29 @@
{
"id": "evt_1GQf15BVqmGyQTMamwMk1kdP",
"object": "event",
"api_version": "2019-12-03",
"created": 1585164955,
"data": {
"object": {
"id": "dlCMiuwT",
"object": "coupon",
"amount_off": null,
"created": 1641498279,
"currency": null,
"duration": "forever",
"duration_in_months": null,
"livemode": false,
"max_redemptions": null,
"metadata": {},
"name": "Test1234",
"percent_off": 50,
"redeem_by": null,
"times_redeemed": 0,
"valid": true
}
},
"livemode": false,
"pending_webhooks": 0,
"request": {},
"type": "coupon.created"
}

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

@ -1020,6 +1020,15 @@ describe('StripeHelper', () => {
});
});
describe('getCoupon', () => {
it('returns a coupon', async () => {
const coupon = { id: 'couponId' };
sandbox.stub(stripeHelper.stripe.coupons, 'retrieve').resolves(coupon);
const actual = await stripeHelper.getCoupon('couponId');
assert.deepEqual(actual, coupon);
});
});
describe('findValidPromoCode', () => {
it('finds a valid promotionCode with plan metadata', async () => {
const promotionCode = { code: 'promo1', coupon: { valid: true } };

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

@ -31,6 +31,7 @@ const eventInvoiceCreated = require('../../payments/fixtures/stripe/event_invoic
const eventInvoicePaid = require('../../payments/fixtures/stripe/event_invoice_paid.json');
const eventInvoicePaidDiscount = require('../../payments/fixtures/stripe/invoice_paid_subscription_create_discount.json');
const eventInvoicePaymentFailed = require('../../payments/fixtures/stripe/event_invoice_payment_failed.json');
const eventCouponCreated = require('../../payments/fixtures/stripe/event_coupon_created.json');
const eventCustomerUpdated = require('../../payments/fixtures/stripe/event_customer_updated.json');
const eventCustomerSubscriptionUpdated = require('../../payments/fixtures/stripe/event_customer_subscription_updated.json');
const eventCustomerSourceExpiring = require('../../payments/fixtures/stripe/event_customer_source_expiring.json');
@ -161,6 +162,7 @@ describe('StripeWebhookHandler', () => {
},
};
const handlerNames = [
'handleCouponEvent',
'handleCustomerCreatedEvent',
'handleSubscriptionCreatedEvent',
'handleSubscriptionUpdatedEvent',
@ -257,6 +259,20 @@ describe('StripeWebhookHandler', () => {
);
});
describe('when the event.type is coupon.created', () => {
itOnlyCallsThisHandler('handleCouponEvent', {
data: { object: { id: 'coupon_123' } },
type: 'coupon.created',
});
});
describe('when the event.type is coupon.updated', () => {
itOnlyCallsThisHandler('handleCouponEvent', {
data: { object: { id: 'coupon_123' } },
type: 'coupon.updated',
});
});
describe('when the event.type is customer.created', () => {
itOnlyCallsThisHandler('handleCustomerCreatedEvent', {
data: { object: customerFixture },
@ -376,6 +392,34 @@ describe('StripeWebhookHandler', () => {
});
});
describe('handleCouponEvents', () => {
for (const eventType of ['coupon.created', 'coupon.updated']) {
it(`allows a valid coupon on ${eventType}`, async () => {
const event = deepCopy(eventCouponCreated);
event.type = eventType;
const coupon = deepCopy(event.data.object);
const sentryModule = require('../../../../lib/sentry');
coupon.applies_to = { products: [] };
sandbox.stub(sentryModule, 'reportSentryError').returns({});
StripeWebhookHandlerInstance.stripeHelper.getCoupon.resolves(coupon);
await StripeWebhookHandlerInstance.handleCouponEvent({}, event);
sinon.assert.notCalled(sentryModule.reportSentryError);
});
it(`reports an error for invalid coupon on ${eventType}`, async () => {
const event = deepCopy(eventCouponCreated);
event.type = eventType;
const coupon = deepCopy(event.data.object);
const sentryModule = require('../../../../lib/sentry');
coupon.applies_to = { products: ['productOhNo'] };
sandbox.stub(sentryModule, 'reportSentryError').returns({});
StripeWebhookHandlerInstance.stripeHelper.getCoupon.resolves(coupon);
await StripeWebhookHandlerInstance.handleCouponEvent({}, event);
sinon.assert.calledOnce(sentryModule.reportSentryError);
});
}
});
describe('handleCustomerCreatedEvent', () => {
it('creates a local db record with the account uid', async () => {
await StripeWebhookHandlerInstance.handleCustomerCreatedEvent(