arlyon / async-stripe

Async (and blocking!) Rust bindings for the Stripe API
https://payments.rs
Apache License 2.0
447 stars 129 forks source link

Webhook Parse failing with "invalid `PriceId`, expected id to start with "price_" or "plan_" #578

Closed thomasmost closed 1 month ago

thomasmost commented 1 month ago

Describe the bug

My invoice.paid webhook event parse is failing with "invalid PriceId, expected id to start with "price" or "plan"

The price ID is "monthly8usd" — and this is from Stripe, mind you... so I think this must be a false presumption?

To Reproduce

Webhook

Event payload (scrubbed):

{
  "id": "evt_xxxm1gHdomXNXiDQq8TfykdS",
  "object": "event",
  "account": "acct_xxxow8HdomXNXiDQ",
  "api_version": "2024-06-20",
  "created": 1721753843,
  "data": {
    "object": {
      "id": "in_xxx1cHdomXNXiDQWDCXCEWg",
      "object": "invoice",
      "account_country": "US",
      "account_name": "Customer.com",
      "account_tax_ids": [
        "atxi_1NZedwHdomXNXiDQIo2Ow1Eg"
      ],
      "amount_due": 0,
      "amount_paid": 0,
      "amount_remaining": 0,
      "amount_shipping": 0,
      "application": null,
      "application_fee_amount": null,
      "attempt_count": 0,
      "attempted": true,
      "auto_advance": false,
      "automatic_tax": {
        "enabled": true,
        "liability": {
          "type": "self"
        },
        "status": "complete"
      },
      "automatically_finalizes_at": null,
      "billing_reason": "subscription_create",
      "charge": null,
      "collection_method": "charge_automatically",
      "created": 1721753840,
      "currency": "usd",
      "custom_fields": null,
      "customer": [""],
      "customer_address": {
        "city": "New York",
        "country": "US",
        "line1": "1 Murray Street",
        "line2": "",
        "postal_code": "10007",
        "state": "NY"
      },
      "customer_email": "customer@customer.com",
      "customer_name": "Customer Customer",
      "customer_phone": null,
      "customer_shipping": null,
      "customer_tax_exempt": "none",
      "default_payment_method": null,
      "default_source": null,
      "default_tax_rates": [
      ],
      "description": null,
      "discount": null,
      "discounts": [
      ],
      "due_date": null,
      "effective_at": 1721753840,
      "ending_balance": 0,
      "footer": null,
      "from_invoice": null,
      "hosted_invoice_url": "",
      "invoice_pdf": "",
      "issuer": {
        "type": "self"
      },
      "last_finalization_error": null,
      "latest_revision": null,
      "lines": {
        "object": "list",
        "data": [
          {
            "id": "il_1Pfm1cHdomXNXiDQJJo9yfOA",
            "object": "line_item",
            "amount": 0,
            "amount_excluding_tax": 0,
            "currency": "usd",
            "description": "Trial period for Twenty (per seats)",
            "discount_amounts": [
            ],
            "discountable": true,
            "discounts": [
            ],
            "invoice": "in_xxxm1cHdomXNXiDQWDCXCEWg",
            "livemode": true,
            "metadata": {
              "workspaceId": "1dc3bff7-ac38-4f7b-944a-ecd8740ccbae"
            },
            "period": {
              "end": 1722358639,
              "start": 1721753839
            },
            "plan": {
              "id": "monthly8usd",
              "object": "plan",
              "active": true,
              "aggregate_usage": null,
              "amount": 900,
              "amount_decimal": "900",
              "billing_scheme": "per_unit",
              "created": 1699860608,
              "currency": "usd",
              "interval": "month",
              "interval_count": 1,
              "livemode": true,
              "metadata": {
                "substack": "yes",
                "inactive": "yes"
              },
              "meter": null,
              "nickname": "Monthly",
              "product": "prod_xxxPg0pCf9PxLe",
              "tiers_mode": null,
              "transform_usage": null,
              "trial_period_days": null,
              "usage_type": "licensed"
            },
            "price": {
              "id": "monthly8usd",
              "object": "price",
              "active": true,
              "billing_scheme": "per_unit",
              "created": 1699860608,
              "currency": "usd",
              "custom_unit_amount": null,
              "livemode": true,
              "lookup_key": null,
              "metadata": {
                "substack": "yes",
                "inactive": "yes"
              },
              "nickname": "Monthly",
              "product": "prod_xxxPg0pCf9PxLe",
              "recurring": {
                "aggregate_usage": null,
                "interval": "month",
                "interval_count": 1,
                "meter": null,
                "trial_period_days": null,
                "usage_type": "licensed"
              },
              "tax_behavior": "exclusive",
              "tiers_mode": null,
              "transform_quantity": null,
              "type": "recurring",
              "unit_amount": 900,
              "unit_amount_decimal": "900"
            },
            "proration": false,
            "proration_details": {
              "credited_items": null
            },
            "quantity": 1,
            "subscription": "sub_xxxx1cHdomXNXiDQsa3Pz1gd",
            "subscription_item": "si_xxxhPtn1K9ivL9",
            "type": "subscription",
            "unit_amount_excluding_tax": "0"
          }
        ],
        "has_more": false,
        "total_count": 1,
        "url": "/v1/invoices/in_1Pfm1cHdomXNXiDQWDCXCEWg/lines"
      },
      "livemode": true,
      "metadata": {
      },
      "next_payment_attempt": null,
      "number": "15B18D14-0001",
      "on_behalf_of": null,
      "paid": true,
      "paid_out_of_band": false,
      "payment_intent": null,
      "payment_settings": {
        "default_mandate": null,
        "payment_method_options": {
          "acss_debit": null,
          "bancontact": null,
          "card": {
            "request_three_d_secure": "automatic"
          },
          "customer_balance": null,
          "konbini": null,
          "sepa_debit": null,
          "us_bank_account": null
        },
        "payment_method_types": null
      },
      "period_end": 1721753839,
      "period_start": 1721753839,
      "post_payment_credit_notes_amount": 0,
      "pre_payment_credit_notes_amount": 0,
      "quote": null,
      "receipt_number": null,
      "rendering": null,
      "shipping_cost": null,
      "shipping_details": null,
      "starting_balance": 0,
      "statement_descriptor": null,
      "status": "paid",
      "status_transitions": {
        "finalized_at": 1721753840,
        "marked_uncollectible_at": null,
        "paid_at": 1721753839,
        "voided_at": null
      },
      "subscription": "sub_xxxm1cHdomXNXiDQsa3Pz1gd",
      "subscription_details": {
        "metadata": {}
      },
      "subtotal": 0,
      "subtotal_excluding_tax": 0,
      "tax": 0,
      "test_clock": null,
      "total": 0,
      "total_discount_amounts": [
      ],
      "total_excluding_tax": 0,
      "total_tax_amounts": [
        {
          "amount": 0,
          "inclusive": false,
          "tax_rate": "txr_1Olak6HdomXNXiDQzkC5j57c",
          "taxability_reason": "not_collecting",
          "taxable_amount": 0
        }
      ],
      "transfer_data": null,
      "webhooks_delivered_at": 1721753840
    }
  },
  "livemode": true,
  "pending_webhooks": 1,
  "request": {
    "id": null,
    "idempotency_key": null
  },
  "type": "invoice.paid"
}

Expected behavior

It should parse a valid invoice.paid event

Code snippets

No response

OS

debian

Rust version

1.79.0

Library version

async-stripe 0.37.1

API version

2024-06-20

Additional context

No response

augustoccesar commented 1 month ago

The PlanId is defined so that it can be any arbitrary string in here. The issue is that, since the "new way" is to use Price instead of Plan, they fill the price data with the plan to keep compatibility.

You can now model subscriptions more flexibly using the Prices API. It replaces the Plans API and is backwards compatible to simplify your migration.

This causes the code to try to parse the price.id that is the arbitrary string from the plan.id, as we can see from the example you sent:

"plan": {
  "id": "monthly8usd",
  "object": "plan",
  (...)
},
"price": {
  "id": "monthly8usd",
  "object": "price",
  (...)
}

I guess there might be the need to lift the restriction of the Price ID needing to be one of price_ or plan_, to instead be the same as the PlanId, any String 🤔

References:

thomasmost commented 1 month ago

Got it! @augustoccesar thank you for the more thorough explanation/understanding than I possessed... I forked the repo earlier to fix this bug in our live service and I just opened a PR. Happy to make adjustments to get it up to merge-standard if you want to take a look!

https://github.com/arlyon/async-stripe/pull/580

arlyon commented 1 month ago

:tada: This issue has been resolved in version 0.38.1 :tada:

The release is available on:

Your semantic-release bot :package::rocket: