diff --git a/packages/fxa-auth-server/lib/payments/stripe.ts b/packages/fxa-auth-server/lib/payments/stripe.ts index 502de189a5..b30037e843 100644 --- a/packages/fxa-auth-server/lib/payments/stripe.ts +++ b/packages/fxa-auth-server/lib/payments/stripe.ts @@ -487,6 +487,21 @@ export class StripeHelper { }, { idempotencyKey: `ssc-${subIdempotencyKey}` } ); + + const paymentIntent = (subscription.latest_invoice as Stripe.Invoice) + .payment_intent as Stripe.PaymentIntent; + + if (paymentIntent?.last_payment_error) { + await this.cancelSubscription(subscription.id); + + throw error.rejectedSubscriptionPaymentToken( + paymentIntent.last_payment_error.code, + new Error( + `Subscription creation failed with error code ${paymentIntent.last_payment_error.code}` + ) + ); + } + const updatedSubscription = await this.postSubscriptionCreationUpdates({ subscription, promotionCode, diff --git a/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_pmi_expanded_incomplete_cvc_fail.json b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_pmi_expanded_incomplete_cvc_fail.json new file mode 100644 index 0000000000..53a1faedba --- /dev/null +++ b/packages/fxa-auth-server/test/local/payments/fixtures/stripe/subscription_pmi_expanded_incomplete_cvc_fail.json @@ -0,0 +1,498 @@ +{ + "id": "sub_1LLsX2JEcmKzuWtRqw2US2v6", + "object": "subscription", + "application_fee_percent": null, + "automatic_tax": { + "enabled": false + }, + "billing_cycle_anchor": 1643130431, + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "collection_method": "charge_automatically", + "created": 1643130431, + "current_period_end": 1645808831, + "current_period_start": 1643130431, + "customer": "cus_E1wCpqeIZU2lcK", + "days_until_due": null, + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "discount": null, + "ended_at": null, + "items": { + "object": "list", + "data": [ + { + "id": "si_E1wItgKCx1KK29", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1643130432, + "metadata": {}, + "plan": { + "id": "price_2Iq6qKLNrqKwuEkKJICpIwKj", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 999, + "amount_decimal": "999", + "billing_scheme": "per_unit", + "created": 1622216612, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": true, + "metadata": { + "downloadURL": "https://vpn.mozilla.org/vpn/download?utm_medium=email&utm_source=email&utm_campaign=subscription-download", + "webIconURL": "https://accounts-static.cdn.mozilla.net/product-icons/mozilla-vpn-web.svg" + }, + "nickname": "VPN Wave 1 Monthly", + "product": "prod_KvrtYEIwqzn2YU", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_2Iq6qKLNrqKwuEkKJICpIwKj", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1622216612, + "currency": "usd", + "livemode": true, + "lookup_key": null, + "metadata": { + "downloadURL": "https://vpn.mozilla.org/vpn/download?utm_medium=email&utm_source=email&utm_campaign=subscription-download", + "webIconURL": "https://accounts-static.cdn.mozilla.net/product-icons/mozilla-vpn-web.svg" + }, + "nickname": "VPN Wave 1 Monthly", + "product": "prod_KvrtYEIwqzn2YU", + "recurring": { + "aggregate_usage": null, + "interval": "month", + "interval_count": 1, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 999, + "unit_amount_decimal": "999" + }, + "quantity": 1, + "subscription": "sub_1LLsX2JEcmKzuWtRqw2US2v6", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1LLsX2JEcmKzuWtRqw2US2v6" + }, + "latest_invoice": { + "id": "in_4HJqU2HNqmGzyKtWChfyj03k", + "object": "invoice", + "account_country": "US", + "account_name": "Mozilla Corporation", + "account_tax_ids": null, + "amount_due": 999, + "amount_paid": 0, + "amount_remaining": 999, + "application_fee_amount": null, + "attempt_count": 1, + "attempted": true, + "auto_advance": true, + "automatic_tax": { + "enabled": false, + "status": null + }, + "billing_reason": "subscription_create", + "charge": "ch_1SKsU2JWvtRquNtE2q20Lqn1", + "collection_method": "charge_automatically", + "created": 1643130432, + "currency": "usd", + "custom_fields": null, + "customer": "cus_E1wCpqeIZU2lcK", + "customer_address": null, + "customer_email": "sonnypeter@example.com", + "customer_name": "Sonny Peter", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discount": null, + "discounts": [], + "due_date": null, + "ending_balance": 0, + "footer": null, + "hosted_invoice_url": "", + "invoice_pdf": "", + "last_finalization_error": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_5TLsE8MKrtPzuCtUxewBKPPM", + "object": "line_item", + "amount": 999, + "currency": "usd", + "description": "1 × Mozilla VPN (at $9.99 / month)", + "discount_amounts": [], + "discountable": true, + "discounts": [], + "livemode": true, + "metadata": {}, + "period": { + "end": 1645808831, + "start": 1643130431 + }, + "plan": { + "id": "price_2Iq6qKLNrqKwuEkKJICpIwKj", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 999, + "amount_decimal": "999", + "billing_scheme": "per_unit", + "created": 1622216612, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": true, + "metadata": { + "downloadURL": "https://vpn.mozilla.org/vpn/download?utm_medium=email&utm_source=email&utm_campaign=subscription-download", + "webIconURL": "https://accounts-static.cdn.mozilla.net/product-icons/mozilla-vpn-web.svg" + }, + "nickname": "VPN Wave 1 Monthly", + "product": "prod_KvrtYEIwqzn2YU", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_2Iq6qKLNrqKwuEkKJICpIwKj", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1622216612, + "currency": "usd", + "livemode": true, + "lookup_key": null, + "metadata": { + "downloadURL": "https://vpn.mozilla.org/vpn/download?utm_medium=email&utm_source=email&utm_campaign=subscription-download", + "webIconURL": "https://accounts-static.cdn.mozilla.net/product-icons/mozilla-vpn-web.svg" + }, + "nickname": "VPN Wave 1 Monthly", + "product": "prod_KvrtYEIwqzn2YU", + "recurring": { + "aggregate_usage": null, + "interval": "month", + "interval_count": 1, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 999, + "unit_amount_decimal": "999" + }, + "proration": false, + "quantity": 1, + "subscription": "sub_1LLsX2JEcmKzuWtRqw2US2v6", + "subscription_item": "si_E1wItgKCx1KK29", + "tax_amounts": [], + "tax_rates": [], + "type": "subscription" + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_4HJqU2HNqmGzyKtWChfyj03k/lines" + }, + "livemode": true, + "metadata": {}, + "next_payment_attempt": null, + "number": "1348121I-0001", + "on_behalf_of": null, + "paid": false, + "paid_out_of_band": false, + "payment_intent": { + "id": "pi_2YLsU2JNcmKzuEtC39sJeUhk", + "object": "payment_intent", + "amount": 999, + "amount_capturable": 0, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "charges": { + "object": "list", + "data": [ + { + "id": "ch_1SKsU2JWvtRquNtE2q20Lqn1", + "object": "charge", + "amount": 999, + "amount_captured": 0, + "amount_refunded": 0, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": null, + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": "78701", + "state": null + }, + "email": null, + "name": null, + "phone": null + }, + "calculated_statement_descriptor": "MOZILLA VPN", + "captured": false, + "created": 1643130432, + "currency": "usd", + "customer": "cus_E1wCpqeIZU2lcK", + "description": "Subscription creation", + "destination": null, + "dispute": null, + "disputed": false, + "failure_code": "incorrect_cvc", + "failure_message": "Your card's security code is incorrect.", + "fraud_details": {}, + "invoice": "in_4HJqU2HNqmGzyKtWChfyj03k", + "livemode": true, + "metadata": {}, + "on_behalf_of": null, + "order": null, + "outcome": { + "network_status": "reversed_after_approval", + "reason": "requested_block_on_incorrect_cvc", + "risk_level": "normal", + "risk_score": 0, + "rule": "block_if_wrong_cvc", + "seller_message": "You requested that Stripe block payments (like this one) for which the customer-entered CVC code does not match the code on file with the card-issuing bank.", + "type": "blocked" + }, + "paid": false, + "payment_intent": "pi_2YLsU2JNcmKzuEtC39sJeUhk", + "payment_method": "pm_5SLsU2ERltPueCtERxUYcltT", + "payment_method_details": { + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": "pass", + "cvc_check": "fail" + }, + "country": "US", + "exp_month": 5, + "exp_year": 2026, + "fingerprint": "r23vKuyhEQrEKxKm", + "funding": "credit", + "installments": null, + "last4": "3808", + "network": "visa", + "three_d_secure": null, + "wallet": null + }, + "type": "card" + }, + "receipt_email": null, + "receipt_number": null, + "receipt_url": null, + "refunded": false, + "refunds": { + "object": "list", + "data": [], + "has_more": false, + "total_count": 0, + "url": "/v1/charges/ch_1SKsU2JWvtRquNtE2q20Lqn1/refunds" + }, + "review": null, + "shipping": null, + "source": null, + "source_transfer": null, + "statement_descriptor": "Mozilla VPN", + "statement_descriptor_suffix": null, + "status": "failed", + "transfer_data": null, + "transfer_group": null + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/charges?payment_intent=pi_2YLsU2JNcmKzuEtC39sJeUhk" + }, + "client_secret": "pi_2YLsU2JNcmKzuEtC39sJeUhk_secret_IuXaPD13KJU8AmIkCWh66tbzO", + "confirmation_method": "automatic", + "created": 1643130432, + "currency": "usd", + "customer": "cus_E1wCpqeIZU2lcK", + "description": "Subscription creation", + "invoice": "in_4HJqU2HNqmGzyKtWChfyj03k", + "last_payment_error": { + "charge": "ch_1SKsU2JWvtRquNtE2q20Lqn1", + "code": "incorrect_cvc", + "doc_url": "https://stripe.com/docs/error-codes/incorrect-cvc", + "message": "Your card's security code is incorrect.", + "param": "cvc", + "payment_method": { + "id": "pm_5SLsU2ERltPueCtERxUYcltT", + "object": "payment_method", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": "78701", + "state": null + }, + "email": null, + "name": null, + "phone": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": "pass", + "cvc_check": "fail" + }, + "country": "US", + "exp_month": 5, + "exp_year": 2026, + "fingerprint": "r23vKuyhEQrEKxKm", + "funding": "credit", + "generated_from": null, + "last4": "3808", + "networks": { + "available": ["visa"], + "preferred": null + }, + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1643130428, + "customer": "cus_E1wCpqeIZU2lcK", + "livemode": true, + "metadata": {}, + "type": "card" + }, + "type": "card_error" + }, + "livemode": true, + "metadata": {}, + "next_action": null, + "on_behalf_of": null, + "payment_method": null, + "payment_method_options": { + "card": { + "installments": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": ["card"], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": "off_session", + "shipping": null, + "source": null, + "statement_descriptor": "Mozilla VPN", + "statement_descriptor_suffix": null, + "status": "requires_payment_method", + "transfer_data": null, + "transfer_group": null + }, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": null + }, + "period_end": 1643130431, + "period_start": 1643130431, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "quote": null, + "receipt_number": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "open", + "status_transitions": { + "finalized_at": 1643130431, + "marked_uncollectible_at": null, + "paid_at": null, + "voided_at": null + }, + "subscription": "sub_1LLsX2JEcmKzuWtRqw2US2v6", + "subtotal": 999, + "tax": null, + "total": 999, + "total_discount_amounts": [], + "total_tax_amounts": [], + "transfer_data": null, + "webhooks_delivered_at": null + }, + "livemode": true, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": null + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "plan": { + "id": "price_2Iq6qKLNrqKwuEkKJICpIwKj", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 999, + "amount_decimal": "999", + "billing_scheme": "per_unit", + "created": 1622216612, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": true, + "metadata": { + "downloadURL": "https://vpn.mozilla.org/vpn/download?utm_medium=email&utm_source=email&utm_campaign=subscription-download", + "webIconURL": "https://accounts-static.cdn.mozilla.net/product-icons/mozilla-vpn-web.svg" + }, + "nickname": "VPN Wave 1 Monthly", + "product": "prod_KvrtYEIwqzn2YU", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start_date": 1643130431, + "status": "incomplete", + "transfer_data": null, + "trial_end": null, + "trial_start": null +} diff --git a/packages/fxa-auth-server/test/local/payments/stripe.js b/packages/fxa-auth-server/test/local/payments/stripe.js index 31f975ab4a..c309fda345 100644 --- a/packages/fxa-auth-server/test/local/payments/stripe.js +++ b/packages/fxa-auth-server/test/local/payments/stripe.js @@ -52,6 +52,7 @@ const subscription1 = require('./fixtures/stripe/subscription1.json'); const subscription2 = require('./fixtures/stripe/subscription2.json'); const multiPlanSubscription = require('./fixtures/stripe/subscription_multiplan.json'); const subscriptionPMIExpanded = require('./fixtures/stripe/subscription_pmi_expanded.json'); +const subscriptionPMIExpandedIncompleteCVCFail = require('./fixtures/stripe/subscription_pmi_expanded_incomplete_cvc_fail.json'); const cancelledSubscription = require('./fixtures/stripe/subscription_cancelled.json'); const pastDueSubscription = require('./fixtures/stripe/subscription_past_due.json'); const paidInvoice = require('./fixtures/stripe/invoice_paid.json'); @@ -822,6 +823,64 @@ describe('StripeHelper', () => { ); }); + it('errors and deletes subscription when a cvc check fails on subscription creation', async () => { + const attachExpected = deepCopy(paymentMethodAttach); + const customerExpected = deepCopy(newCustomerPM); + sandbox + .stub(stripeHelper.stripe.paymentMethods, 'attach') + .resolves(attachExpected); + sandbox + .stub(stripeHelper.stripe.customers, 'update') + .resolves(customerExpected); + sandbox + .stub(stripeHelper.stripe.subscriptions, 'create') + .resolves(subscriptionPMIExpandedIncompleteCVCFail); + sandbox.stub(stripeHelper, 'cancelSubscription').resolves({}); + const subIdempotencyKey = uuidv4(); + stripeFirestore.insertCustomerRecordWithBackfill = sandbox + .stub() + .resolves({}); + stripeFirestore.insertSubscriptionRecordWithBackfill = sandbox + .stub() + .resolves({}); + stripeFirestore.insertPaymentMethodRecord = sandbox.stub().resolves({}); + + try { + await stripeHelper.createSubscriptionWithPMI({ + customerId: 'customerId', + priceId: 'priceId', + paymentMethodId: 'pm_1H0FRp2eZvKYlo2CeIZoc0wj', + subIdempotencyKey, + taxRateId: 'tr_asdf', + }); + sinon.assert.fail(); + } catch (err) { + assert.equal( + err.errno, + error.ERRNO.REJECTED_SUBSCRIPTION_PAYMENT_TOKEN + ); + } + sinon.assert.calledOnceWithExactly( + stripeHelper.stripe.subscriptions.create, + { + customer: 'customerId', + items: [{ price: 'priceId' }], + expand: ['latest_invoice.payment_intent'], + default_tax_rates: ['tr_asdf'], + promotion_code: undefined, + }, + { idempotencyKey: `ssc-${subIdempotencyKey}` } + ); + sinon.assert.calledOnceWithExactly( + stripeHelper.cancelSubscription, + subscriptionPMIExpandedIncompleteCVCFail.id + ); + sinon.assert.notCalled( + stripeFirestore.insertSubscriptionRecordWithBackfill + ); + sinon.assert.callCount(mockStatsd.increment, 1); + }); + it('surfaces payment issues', async () => { const apiError = new stripeError.StripeCardError(); sandbox