From 28ed143af14571080d8b05aa76b3fd454cbd1a0e Mon Sep 17 00:00:00 2001 From: Ben Bangert Date: Thu, 6 Jan 2022 12:02:36 -0800 Subject: [PATCH] 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 --- .../fxa-auth-server/lib/payments/stripe.ts | 7 +++ .../routes/subscriptions/stripe-webhook.ts | 23 ++++++++++ .../fixtures/stripe/event_coupon_created.json | 29 ++++++++++++ .../test/local/payments/stripe.js | 9 ++++ .../routes/subscriptions/stripe-webhooks.js | 44 +++++++++++++++++++ 5 files changed, 112 insertions(+) create mode 100644 packages/fxa-auth-server/test/local/payments/fixtures/stripe/event_coupon_created.json diff --git a/packages/fxa-auth-server/lib/payments/stripe.ts b/packages/fxa-auth-server/lib/payments/stripe.ts index 10c70c2ed2..23ef5812af 100644 --- a/packages/fxa-auth-server/lib/payments/stripe.ts +++ b/packages/fxa-auth-server/lib/payments/stripe.ts @@ -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 diff --git a/packages/fxa-auth-server/lib/routes/subscriptions/stripe-webhook.ts b/packages/fxa-auth-server/lib/routes/subscriptions/stripe-webhook.ts index 66320eca4d..3d0361629a 100644 --- a/packages/fxa-auth-server/lib/routes/subscriptions/stripe-webhook.ts +++ b/packages/fxa-auth-server/lib/routes/subscriptions/stripe-webhook.ts @@ -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. */ diff --git a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/event_coupon_created.json b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/event_coupon_created.json new file mode 100644 index 0000000000..c2e6198627 --- /dev/null +++ b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/event_coupon_created.json @@ -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" +} diff --git a/packages/fxa-auth-server/test/local/payments/stripe.js b/packages/fxa-auth-server/test/local/payments/stripe.js index 83c8a01543..15e9910843 100644 --- a/packages/fxa-auth-server/test/local/payments/stripe.js +++ b/packages/fxa-auth-server/test/local/payments/stripe.js @@ -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 } }; diff --git a/packages/fxa-auth-server/test/local/routes/subscriptions/stripe-webhooks.js b/packages/fxa-auth-server/test/local/routes/subscriptions/stripe-webhooks.js index 6d598686dc..253d1b7654 100644 --- a/packages/fxa-auth-server/test/local/routes/subscriptions/stripe-webhooks.js +++ b/packages/fxa-auth-server/test/local/routes/subscriptions/stripe-webhooks.js @@ -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(