fix(subscriptions): Duplicate charge on cvc fail (#12046)

* fix(subscriptions): Duplicate charge on cvc fail
Because:

* An incorrect card cvc isn't always immediately caught, which causes an
  incomplete subscription. Once the customer corrects the cvc it could
  happen that a new subscription is created, instead of completing the
  existing subscription. As a result, both subscriptions are completed
  and the card is charged twice.

This commit:

* Ensures that only 1 subscription is created and the card is only
  charged once.

Closes #11024
This commit is contained in:
Reino Muhl 2022-03-03 16:12:04 -05:00 коммит произвёл GitHub
Родитель f31ec81756
Коммит b5b3856104
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
3 изменённых файлов: 572 добавлений и 0 удалений

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

@ -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,

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

@ -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
}

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

@ -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