From 61d1f0320359f62b7fa3b85ba3f029cbcffb2647 Mon Sep 17 00:00:00 2001 From: Eric Seidel Date: Wed, 30 Jul 2025 15:08:23 -0700 Subject: [PATCH] feat: move stripe_api into our public repo (#3260) Co-authored-by: Felix Angelov --- .github/workflows/main.yaml | 5 + README.md | 1 + .../redis_client/lib/src/redis_client.dart | 2 +- packages/stripe_api/.gitignore | 11 + packages/stripe_api/CONTRIBUTING.md | 11 + packages/stripe_api/LICENSE | 19 + packages/stripe_api/README.md | 55 +++ packages/stripe_api/analysis_options.yaml | 1 + packages/stripe_api/build.yaml | 16 + .../lib/src/converters/converters.dart | 1 + .../src/converters/timestamp_converter.dart | 16 + .../lib/src/models/internal/internal.dart | 1 + .../src/models/internal/paged_response.dart | 24 ++ .../src/models/internal/paged_response.g.dart | 21 + .../stripe_api/lib/src/models/models.dart | 8 + .../src/models/stripe_checkout_session.dart | 29 ++ .../src/models/stripe_checkout_session.g.dart | 19 + .../lib/src/models/stripe_customer.dart | 47 +++ .../lib/src/models/stripe_customer.g.dart | 23 ++ .../lib/src/models/stripe_event.dart | 50 +++ .../lib/src/models/stripe_event.g.dart | 22 ++ .../models/stripe_meter_event_summary.dart | 44 +++ .../models/stripe_meter_event_summary.g.dart | 51 +++ .../lib/src/models/stripe_price.dart | 147 +++++++ .../lib/src/models/stripe_price.g.dart | 70 ++++ .../lib/src/models/stripe_price_tier.dart | 42 ++ .../lib/src/models/stripe_price_tier.g.dart | 39 ++ .../lib/src/models/stripe_subscription.dart | 145 +++++++ .../lib/src/models/stripe_subscription.g.dart | 100 +++++ .../src/models/stripe_subscription_item.dart | 34 ++ .../models/stripe_subscription_item.g.dart | 23 ++ packages/stripe_api/lib/src/stripe_api.dart | 174 +++++++++ packages/stripe_api/lib/stripe_api.dart | 2 + packages/stripe_api/pubspec.yaml | 20 + .../checkout_session_completed_event.json | 95 +++++ .../stripe/json/create_payment_link.json | 45 +++ .../test/fixtures/stripe/json/customer.json | 29 ++ .../customer_search_multiple_results.json | 69 ++++ .../json/customer_search_no_results.json | 6 + .../json/customer_search_one_result.json | 38 ++ ...ne_result_with_expanded_subscriptions.json | 193 ++++++++++ .../customer_with_empty_subscriptions.json | 36 ++ .../customer_with_inactive_subscription.json | 183 +++++++++ .../json/customer_with_subscription.json | 183 +++++++++ .../meter_event_summaries_no_more_pages.json | 16 + ...meter_event_summaries_with_more_pages.json | 16 + .../json/pay_as_you_go_subscription_item.json | 60 +++ .../platform_access_subscription_item.json | 65 ++++ .../fixtures/stripe/json/subscription.json | 146 +++++++ .../json/subscription_created_event.json | 161 ++++++++ .../json/subscription_with_pricing_tiers.json | 197 ++++++++++ .../stripe/json/trialing_subscription.json | 161 ++++++++ .../test/fixtures/stripe/stripe_fixtures.dart | 95 +++++ .../converters/timestamp_converter_test.dart | 27 ++ .../models/stripe_checkout_session_test.dart | 17 + .../test/src/models/stripe_customer_test.dart | 42 ++ .../test/src/models/stripe_event_test.dart | 43 +++ .../test/src/models/stripe_price_test.dart | 175 +++++++++ .../src/models/stripe_subscription_test.dart | 210 ++++++++++ .../stripe_api/test/src/stripe_api_test.dart | 364 ++++++++++++++++++ pubspec.yaml | 1 + 61 files changed, 3945 insertions(+), 1 deletion(-) create mode 100644 packages/stripe_api/.gitignore create mode 100644 packages/stripe_api/CONTRIBUTING.md create mode 100644 packages/stripe_api/LICENSE create mode 100644 packages/stripe_api/README.md create mode 100644 packages/stripe_api/analysis_options.yaml create mode 100644 packages/stripe_api/build.yaml create mode 100644 packages/stripe_api/lib/src/converters/converters.dart create mode 100644 packages/stripe_api/lib/src/converters/timestamp_converter.dart create mode 100644 packages/stripe_api/lib/src/models/internal/internal.dart create mode 100644 packages/stripe_api/lib/src/models/internal/paged_response.dart create mode 100644 packages/stripe_api/lib/src/models/internal/paged_response.g.dart create mode 100644 packages/stripe_api/lib/src/models/models.dart create mode 100644 packages/stripe_api/lib/src/models/stripe_checkout_session.dart create mode 100644 packages/stripe_api/lib/src/models/stripe_checkout_session.g.dart create mode 100644 packages/stripe_api/lib/src/models/stripe_customer.dart create mode 100644 packages/stripe_api/lib/src/models/stripe_customer.g.dart create mode 100644 packages/stripe_api/lib/src/models/stripe_event.dart create mode 100644 packages/stripe_api/lib/src/models/stripe_event.g.dart create mode 100644 packages/stripe_api/lib/src/models/stripe_meter_event_summary.dart create mode 100644 packages/stripe_api/lib/src/models/stripe_meter_event_summary.g.dart create mode 100644 packages/stripe_api/lib/src/models/stripe_price.dart create mode 100644 packages/stripe_api/lib/src/models/stripe_price.g.dart create mode 100644 packages/stripe_api/lib/src/models/stripe_price_tier.dart create mode 100644 packages/stripe_api/lib/src/models/stripe_price_tier.g.dart create mode 100644 packages/stripe_api/lib/src/models/stripe_subscription.dart create mode 100644 packages/stripe_api/lib/src/models/stripe_subscription.g.dart create mode 100644 packages/stripe_api/lib/src/models/stripe_subscription_item.dart create mode 100644 packages/stripe_api/lib/src/models/stripe_subscription_item.g.dart create mode 100644 packages/stripe_api/lib/src/stripe_api.dart create mode 100644 packages/stripe_api/lib/stripe_api.dart create mode 100644 packages/stripe_api/pubspec.yaml create mode 100644 packages/stripe_api/test/fixtures/stripe/json/checkout_session_completed_event.json create mode 100644 packages/stripe_api/test/fixtures/stripe/json/create_payment_link.json create mode 100644 packages/stripe_api/test/fixtures/stripe/json/customer.json create mode 100644 packages/stripe_api/test/fixtures/stripe/json/customer_search_multiple_results.json create mode 100644 packages/stripe_api/test/fixtures/stripe/json/customer_search_no_results.json create mode 100644 packages/stripe_api/test/fixtures/stripe/json/customer_search_one_result.json create mode 100644 packages/stripe_api/test/fixtures/stripe/json/customer_search_one_result_with_expanded_subscriptions.json create mode 100644 packages/stripe_api/test/fixtures/stripe/json/customer_with_empty_subscriptions.json create mode 100644 packages/stripe_api/test/fixtures/stripe/json/customer_with_inactive_subscription.json create mode 100644 packages/stripe_api/test/fixtures/stripe/json/customer_with_subscription.json create mode 100644 packages/stripe_api/test/fixtures/stripe/json/meter_event_summaries_no_more_pages.json create mode 100644 packages/stripe_api/test/fixtures/stripe/json/meter_event_summaries_with_more_pages.json create mode 100644 packages/stripe_api/test/fixtures/stripe/json/pay_as_you_go_subscription_item.json create mode 100644 packages/stripe_api/test/fixtures/stripe/json/platform_access_subscription_item.json create mode 100644 packages/stripe_api/test/fixtures/stripe/json/subscription.json create mode 100644 packages/stripe_api/test/fixtures/stripe/json/subscription_created_event.json create mode 100644 packages/stripe_api/test/fixtures/stripe/json/subscription_with_pricing_tiers.json create mode 100644 packages/stripe_api/test/fixtures/stripe/json/trialing_subscription.json create mode 100644 packages/stripe_api/test/fixtures/stripe/stripe_fixtures.dart create mode 100644 packages/stripe_api/test/src/converters/timestamp_converter_test.dart create mode 100644 packages/stripe_api/test/src/models/stripe_checkout_session_test.dart create mode 100644 packages/stripe_api/test/src/models/stripe_customer_test.dart create mode 100644 packages/stripe_api/test/src/models/stripe_event_test.dart create mode 100644 packages/stripe_api/test/src/models/stripe_price_test.dart create mode 100644 packages/stripe_api/test/src/models/stripe_subscription_test.dart create mode 100644 packages/stripe_api/test/src/stripe_api_test.dart diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index c3429f98..9f3a3f59 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -97,6 +97,11 @@ jobs: - ./.github/workflows/main.yaml - ./.github/actions/dart_package/action.yaml - packages/scoped_deps/** + stripe_api: + - ./.github/codecov.yml + - ./.github/workflows/main.yaml + - ./.github/actions/dart_package/action.yaml + - packages/stripe_api/** - uses: dorny/paths-filter@v3 name: Redis Detection diff --git a/README.md b/README.md index 52580858..c0755b05 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ This repository is a monorepo containing the following packages: | [jwt](packages/jwt/README.md) | Dart library for verifying JSON Web Tokens | | [redis_client](packages/redis_client/README.md) | Dart library for interacting with Redis | | [scoped_deps](packages/scoped_deps/README.md) | A simple dependency injection library built on Zones | +| [stripe_api](packages/stripe_api/README.md) | Dart library for interacting with Stripe | For more information, please refer to the documentation for each package. diff --git a/packages/redis_client/lib/src/redis_client.dart b/packages/redis_client/lib/src/redis_client.dart index 2253c0d9..08f4313c 100644 --- a/packages/redis_client/lib/src/redis_client.dart +++ b/packages/redis_client/lib/src/redis_client.dart @@ -716,7 +716,7 @@ enum RedisTimeSeriesAggregator { /// Sample variance of the values sampleVariance, - /// Time-weighted average over the bucket's timeframe + /// Time-weighted average over the bucket's time frame timeWeightedAverage; /// Converts the enum to an argument that can be passed directly to diff --git a/packages/stripe_api/.gitignore b/packages/stripe_api/.gitignore new file mode 100644 index 00000000..40a8f4ab --- /dev/null +++ b/packages/stripe_api/.gitignore @@ -0,0 +1,11 @@ +# See https://www.dartlang.org/guides/libraries/private-files + +# Files and directories created by pub +.dart_tool/ +.packages +build/ +pubspec.lock + +# Test related files +coverage/ +coverage_badge.svg diff --git a/packages/stripe_api/CONTRIBUTING.md b/packages/stripe_api/CONTRIBUTING.md new file mode 100644 index 00000000..b50c4233 --- /dev/null +++ b/packages/stripe_api/CONTRIBUTING.md @@ -0,0 +1,11 @@ +# Contributing + +We are happy to accept contributions! + +## Developing + +This library has 100% coverage and all PRs are expected to be tested. + +### Running Tests + +All you need to do is run `dart test`. \ No newline at end of file diff --git a/packages/stripe_api/LICENSE b/packages/stripe_api/LICENSE new file mode 100644 index 00000000..6802bc4b --- /dev/null +++ b/packages/stripe_api/LICENSE @@ -0,0 +1,19 @@ +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/stripe_api/README.md b/packages/stripe_api/README.md new file mode 100644 index 00000000..739831b0 --- /dev/null +++ b/packages/stripe_api/README.md @@ -0,0 +1,55 @@ +# 🐦 💳 Shorebird Stripe Client + +[![Discord][discord_badge]][discord_link] + +[![ci][ci_badge]][ci_link] +[![codecov][codecov_badge]][codecov_link] +[![License: MIT][license_badge]][license_link] + +A Dart library for interacting with [Stripe](https://stripe.com). + +Built with 💙 by [Shorebird][shorebird_link]. + +## About + +This is just enough of the Stripe API to implement what Shorebird needs. + +We built this after failing to find a good server-side Stripe package for Dart. + +It certainly could be expanded to support more of the Stripe API. + +Currently hand-rolled, but we could consider generating it from +Stripe's OpenAPI spec in the future. + +This is not currently published on [pub.dev](https://pub.dev), just developed in the open in hopes of +saving someone else time. + +## Join us on Discord! 💬 + +We have an active [Discord server][discord_link] where you can +ask questions and get help. + +## Contributing 🤝 + +See [CONTRIBUTING.md](CONTRIBUTING.md). + +## License 📃 + +Shorebird packages are licensed for use under either of the following at your option: + +- [Apache License, Version 2.0][apache_link] +- [MIT license][mit_link] + +See our [license philosophy](https://github.com/shorebirdtech/handbook/blob/main/engineering.md#licensing-philosophy) for more information on why we license files this way. + +[apache_link]: https://www.apache.org/licenses/LICENSE-2.0 +[ci_badge]: https://github.com/shorebirdtech/shorebird/actions/workflows/main.yaml/badge.svg +[ci_link]: https://github.com/shorebirdtech/shorebird/actions/workflows/main.yaml +[codecov_badge]: https://codecov.io/gh/shorebirdtech/shorebird/branch/main/graph/badge.svg +[codecov_link]: https://codecov.io/gh/shorebirdtech/shorebird +[discord_badge]: https://dcbadge.vercel.app/api/server/shorebird +[discord_link]: https://discord.gg/shorebird +[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg +[license_link]: https://opensource.org/licenses/MIT +[mit_link]: https://opensource.org/licenses/MIT +[shorebird_link]: https://shorebird.dev diff --git a/packages/stripe_api/analysis_options.yaml b/packages/stripe_api/analysis_options.yaml new file mode 100644 index 00000000..f04c6cf0 --- /dev/null +++ b/packages/stripe_api/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../analysis_options.yaml diff --git a/packages/stripe_api/build.yaml b/packages/stripe_api/build.yaml new file mode 100644 index 00000000..edc6c369 --- /dev/null +++ b/packages/stripe_api/build.yaml @@ -0,0 +1,16 @@ +targets: + $default: + builders: + source_gen|combining_builder: + options: + ignore_for_file: + - implicit_dynamic_parameter + - require_trailing_commas + - cast_nullable_to_non_nullable + - lines_longer_than_80_chars + - unnecessary_lambdas + json_serializable: + options: + field_rename: snake + checked: true + explicit_to_json: true diff --git a/packages/stripe_api/lib/src/converters/converters.dart b/packages/stripe_api/lib/src/converters/converters.dart new file mode 100644 index 00000000..0f233241 --- /dev/null +++ b/packages/stripe_api/lib/src/converters/converters.dart @@ -0,0 +1 @@ +export 'timestamp_converter.dart'; diff --git a/packages/stripe_api/lib/src/converters/timestamp_converter.dart b/packages/stripe_api/lib/src/converters/timestamp_converter.dart new file mode 100644 index 00000000..f6eb4d7f --- /dev/null +++ b/packages/stripe_api/lib/src/converters/timestamp_converter.dart @@ -0,0 +1,16 @@ +import 'package:json_annotation/json_annotation.dart'; + +/// {@template timestamp_converter} +/// Converts between Unix timestamps and [DateTime]. +/// {@endtemplate} +class TimestampConverter implements JsonConverter { + /// {@macro timestamp_converter} + const TimestampConverter(); + + @override + DateTime fromJson(int timestamp) => + DateTime.fromMillisecondsSinceEpoch(timestamp * 1000); + + @override + int toJson(DateTime dateTime) => dateTime.millisecondsSinceEpoch ~/ 1000; +} diff --git a/packages/stripe_api/lib/src/models/internal/internal.dart b/packages/stripe_api/lib/src/models/internal/internal.dart new file mode 100644 index 00000000..d8243fa3 --- /dev/null +++ b/packages/stripe_api/lib/src/models/internal/internal.dart @@ -0,0 +1 @@ +export 'paged_response.dart'; diff --git a/packages/stripe_api/lib/src/models/internal/paged_response.dart b/packages/stripe_api/lib/src/models/internal/paged_response.dart new file mode 100644 index 00000000..33c97119 --- /dev/null +++ b/packages/stripe_api/lib/src/models/internal/paged_response.dart @@ -0,0 +1,24 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'paged_response.g.dart'; + +/// {@template paged_response} +/// A response that contains a list of data and a flag indicating if there is +/// more data available. +/// See https://stripe.com/docs/api/pagination. +/// {@endtemplate} +@JsonSerializable() +class PagedResponse { + /// {@macro paged_response} + PagedResponse({required this.data, required this.hasMore}); + + /// Converts a `Map` to a [PagedResponse]. + factory PagedResponse.fromJson(Map json) => + _$PagedResponseFromJson(json); + + /// The data in this page of the response. + final List data; + + /// Whether there are more pages of data available. + final bool hasMore; +} diff --git a/packages/stripe_api/lib/src/models/internal/paged_response.g.dart b/packages/stripe_api/lib/src/models/internal/paged_response.g.dart new file mode 100644 index 00000000..99092498 --- /dev/null +++ b/packages/stripe_api/lib/src/models/internal/paged_response.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: implicit_dynamic_parameter, require_trailing_commas, cast_nullable_to_non_nullable, lines_longer_than_80_chars, unnecessary_lambdas + +part of 'paged_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PagedResponse _$PagedResponseFromJson(Map json) => + $checkedCreate('PagedResponse', json, ($checkedConvert) { + final val = PagedResponse( + data: $checkedConvert('data', (v) => v as List), + hasMore: $checkedConvert('has_more', (v) => v as bool), + ); + return val; + }, fieldKeyMap: const {'hasMore': 'has_more'}); + +Map _$PagedResponseToJson(PagedResponse instance) => + {'data': instance.data, 'has_more': instance.hasMore}; diff --git a/packages/stripe_api/lib/src/models/models.dart b/packages/stripe_api/lib/src/models/models.dart new file mode 100644 index 00000000..b5636972 --- /dev/null +++ b/packages/stripe_api/lib/src/models/models.dart @@ -0,0 +1,8 @@ +export 'stripe_checkout_session.dart'; +export 'stripe_customer.dart'; +export 'stripe_event.dart'; +export 'stripe_meter_event_summary.dart'; +export 'stripe_price.dart'; +export 'stripe_price_tier.dart'; +export 'stripe_subscription.dart'; +export 'stripe_subscription_item.dart'; diff --git a/packages/stripe_api/lib/src/models/stripe_checkout_session.dart b/packages/stripe_api/lib/src/models/stripe_checkout_session.dart new file mode 100644 index 00000000..b59f167d --- /dev/null +++ b/packages/stripe_api/lib/src/models/stripe_checkout_session.dart @@ -0,0 +1,29 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'stripe_checkout_session.g.dart'; + +/// {@template stripe_checkout_session} +/// A Checkout Session represents your customer's session as they pay for +/// one-time purchases or subscriptions through Checkout or Payment Links. +/// +/// See https://stripe.com/docs/api/checkout/sessions/object. +/// {@endtemplate} +@JsonSerializable(createToJson: false) +class StripeCheckoutSession { + /// {@macro stripe_checkout_session} + StripeCheckoutSession({required this.customerId, required this.metadata}); + + /// Creates a [StripeCheckoutSession] from a JSON object. + factory StripeCheckoutSession.fromJson(Map json) => + _$StripeCheckoutSessionFromJson(json); + + /// The Stripe customer ID associated with this [StripeCheckoutSession]. + @JsonKey(name: 'customer') + final String customerId; + + /// Extra data associated with this [StripeCheckoutSession]. + /// + /// We use this to store the email address of the customer using the + /// "shorebird_email" key. + final Map metadata; +} diff --git a/packages/stripe_api/lib/src/models/stripe_checkout_session.g.dart b/packages/stripe_api/lib/src/models/stripe_checkout_session.g.dart new file mode 100644 index 00000000..7502960e --- /dev/null +++ b/packages/stripe_api/lib/src/models/stripe_checkout_session.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: implicit_dynamic_parameter, require_trailing_commas, cast_nullable_to_non_nullable, lines_longer_than_80_chars, unnecessary_lambdas + +part of 'stripe_checkout_session.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +StripeCheckoutSession _$StripeCheckoutSessionFromJson( + Map json, +) => $checkedCreate('StripeCheckoutSession', json, ($checkedConvert) { + final val = StripeCheckoutSession( + customerId: $checkedConvert('customer', (v) => v as String), + metadata: $checkedConvert('metadata', (v) => v as Map), + ); + return val; +}, fieldKeyMap: const {'customerId': 'customer'}); diff --git a/packages/stripe_api/lib/src/models/stripe_customer.dart b/packages/stripe_api/lib/src/models/stripe_customer.dart new file mode 100644 index 00000000..d39d832e --- /dev/null +++ b/packages/stripe_api/lib/src/models/stripe_customer.dart @@ -0,0 +1,47 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:stripe_api/stripe_api.dart'; + +part 'stripe_customer.g.dart'; + +/// {@template stripe_customer} +/// A partial Dart representation of the Customer object from Stripe's API. +/// +/// See https://stripe.com/docs/api/customers/object. +/// {@endtemplate} +@JsonSerializable(createToJson: false) +class StripeCustomer { + /// {@macro stripe_customer} + StripeCustomer({required this.id, this.name, this.email, this.subscriptions}); + + /// Converts a `Map` to a [StripeCustomer] + factory StripeCustomer.fromJson(Map json) => + _$StripeCustomerFromJson(json); + + /// The unique identifier for this customer. + final String id; + + /// The customer’s full name or business name. + final String? name; + + /// The email address associated with this customer. + final String? email; + + /// The customer’s current subscriptions, if any. + @JsonKey(fromJson: _subscriptionsFromJson) + final List? subscriptions; + + @override + String toString() => '$name - $email (id:$id)'; +} + +List? _subscriptionsFromJson(Map? json) { + final data = json?['data'] as List?; + if (data == null) { + return null; + } + + return data + .whereType>() + .map(StripeSubscription.fromJson) + .toList(); +} diff --git a/packages/stripe_api/lib/src/models/stripe_customer.g.dart b/packages/stripe_api/lib/src/models/stripe_customer.g.dart new file mode 100644 index 00000000..cf0c83ec --- /dev/null +++ b/packages/stripe_api/lib/src/models/stripe_customer.g.dart @@ -0,0 +1,23 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: implicit_dynamic_parameter, require_trailing_commas, cast_nullable_to_non_nullable, lines_longer_than_80_chars, unnecessary_lambdas + +part of 'stripe_customer.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +StripeCustomer _$StripeCustomerFromJson(Map json) => + $checkedCreate('StripeCustomer', json, ($checkedConvert) { + final val = StripeCustomer( + id: $checkedConvert('id', (v) => v as String), + name: $checkedConvert('name', (v) => v as String?), + email: $checkedConvert('email', (v) => v as String?), + subscriptions: $checkedConvert( + 'subscriptions', + (v) => _subscriptionsFromJson(v as Map?), + ), + ); + return val; + }); diff --git a/packages/stripe_api/lib/src/models/stripe_event.dart b/packages/stripe_api/lib/src/models/stripe_event.dart new file mode 100644 index 00000000..158ee50a --- /dev/null +++ b/packages/stripe_api/lib/src/models/stripe_event.dart @@ -0,0 +1,50 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:stripe_api/src/converters/converters.dart'; +import 'package:stripe_api/stripe_api.dart'; + +part 'stripe_event.g.dart'; + +/// {@template stripe_event} +/// The contents of a Stripe webhook event. +/// +/// See https://stripe.com/docs/webhooks/stripe-events#event-object-structure. +/// {@endtemplate} +@JsonSerializable(createToJson: false) +class StripeEvent { + /// {@macro stripe_event} + StripeEvent({ + required this.id, + required this.jsonData, + required this.created, + }) { + final objectJson = jsonData['object'] as Map; + final objectType = objectJson['object'] as String; + switch (objectType) { + case 'checkout.session': + object = StripeCheckoutSession.fromJson(objectJson) as T; + case 'subscription': + object = StripeSubscription.fromJson(objectJson) as T; + default: + throw Exception('Unknown Stripe object type: $objectType'); + } + } + + /// Creates a [StripeEvent] from a JSON object. + factory StripeEvent.fromJson(Map json) => + _$StripeEventFromJson(json); + + /// The unique identifier for this event. + final String id; + + /// When this event was created. + @TimestampConverter() + final DateTime created; + + /// The object payload of this event. + @JsonKey(name: 'data') + final Map jsonData; + + /// The deserialized payload of this event. + @JsonKey(includeFromJson: false) + late final T object; +} diff --git a/packages/stripe_api/lib/src/models/stripe_event.g.dart b/packages/stripe_api/lib/src/models/stripe_event.g.dart new file mode 100644 index 00000000..27573e5a --- /dev/null +++ b/packages/stripe_api/lib/src/models/stripe_event.g.dart @@ -0,0 +1,22 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: implicit_dynamic_parameter, require_trailing_commas, cast_nullable_to_non_nullable, lines_longer_than_80_chars, unnecessary_lambdas + +part of 'stripe_event.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +StripeEvent _$StripeEventFromJson(Map json) => + $checkedCreate('StripeEvent', json, ($checkedConvert) { + final val = StripeEvent( + id: $checkedConvert('id', (v) => v as String), + jsonData: $checkedConvert('data', (v) => v as Map), + created: $checkedConvert( + 'created', + (v) => const TimestampConverter().fromJson((v as num).toInt()), + ), + ); + return val; + }, fieldKeyMap: const {'jsonData': 'data'}); diff --git a/packages/stripe_api/lib/src/models/stripe_meter_event_summary.dart b/packages/stripe_api/lib/src/models/stripe_meter_event_summary.dart new file mode 100644 index 00000000..c7fd52d3 --- /dev/null +++ b/packages/stripe_api/lib/src/models/stripe_meter_event_summary.dart @@ -0,0 +1,44 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:stripe_api/src/converters/converters.dart'; + +part 'stripe_meter_event_summary.g.dart'; + +/// {@template meter_event_summary} +/// A summary of meter events for a given time window. +/// {@endtemplate} +@JsonSerializable() +class StripeMeterEventSummary { + /// {@macro meter_event_summary} + StripeMeterEventSummary({ + required this.id, + required this.aggregatedValue, + required this.startTime, + required this.endTime, + required this.meterId, + }); + + /// Converts a `Map` to a [StripeMeterEventSummary]. + factory StripeMeterEventSummary.fromJson(Map json) => + _$StripeMeterEventSummaryFromJson(json); + + /// Converts a [StripeMeterEventSummary] to a `Map`. + Map toJson() => _$StripeMeterEventSummaryToJson(this); + + /// The unique identifier for this object. + final String id; + + /// The value of all events reported in this time window. + final double aggregatedValue; + + /// The start of this summarized time window. + @TimestampConverter() + final DateTime startTime; + + /// The end of this summarized time window. + @TimestampConverter() + final DateTime endTime; + + /// The id of the event meter this object is summarizing. + @JsonKey(name: 'meter') + final String meterId; +} diff --git a/packages/stripe_api/lib/src/models/stripe_meter_event_summary.g.dart b/packages/stripe_api/lib/src/models/stripe_meter_event_summary.g.dart new file mode 100644 index 00000000..c293274f --- /dev/null +++ b/packages/stripe_api/lib/src/models/stripe_meter_event_summary.g.dart @@ -0,0 +1,51 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: implicit_dynamic_parameter, require_trailing_commas, cast_nullable_to_non_nullable, lines_longer_than_80_chars, unnecessary_lambdas + +part of 'stripe_meter_event_summary.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +StripeMeterEventSummary _$StripeMeterEventSummaryFromJson( + Map json, +) => $checkedCreate( + 'StripeMeterEventSummary', + json, + ($checkedConvert) { + final val = StripeMeterEventSummary( + id: $checkedConvert('id', (v) => v as String), + aggregatedValue: $checkedConvert( + 'aggregated_value', + (v) => (v as num).toDouble(), + ), + startTime: $checkedConvert( + 'start_time', + (v) => const TimestampConverter().fromJson((v as num).toInt()), + ), + endTime: $checkedConvert( + 'end_time', + (v) => const TimestampConverter().fromJson((v as num).toInt()), + ), + meterId: $checkedConvert('meter', (v) => v as String), + ); + return val; + }, + fieldKeyMap: const { + 'aggregatedValue': 'aggregated_value', + 'startTime': 'start_time', + 'endTime': 'end_time', + 'meterId': 'meter', + }, +); + +Map _$StripeMeterEventSummaryToJson( + StripeMeterEventSummary instance, +) => { + 'id': instance.id, + 'aggregated_value': instance.aggregatedValue, + 'start_time': const TimestampConverter().toJson(instance.startTime), + 'end_time': const TimestampConverter().toJson(instance.endTime), + 'meter': instance.meterId, +}; diff --git a/packages/stripe_api/lib/src/models/stripe_price.dart b/packages/stripe_api/lib/src/models/stripe_price.dart new file mode 100644 index 00000000..44856349 --- /dev/null +++ b/packages/stripe_api/lib/src/models/stripe_price.dart @@ -0,0 +1,147 @@ +import 'package:collection/collection.dart'; +import 'package:decimal/decimal.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:stripe_api/src/models/stripe_price_tier.dart'; + +part 'stripe_price.g.dart'; + +/// {@template billing_scheme} +/// An enum representing the billing scheme of a [StripePrice]. +/// +/// https://docs.stripe.com/api/prices/object#price_object-billing_scheme +/// {@endtemplate} +@JsonEnum(fieldRename: FieldRename.snake) +enum BillingScheme { + /// Per unit pricing refers to the unit_price. + perUnit('per_unit'), + + /// Tiered pricing refers to the tiers of the price. + tiered('tiered'); + + /// {@macro billing_scheme} + const BillingScheme(this.value); + + /// The [String] value. + final String value; +} + +/// {@template usage_type} +/// Whether the price is based on usage (metered) or the quantity in the +/// subscription (licensed). +/// https://docs.stripe.com/api/prices/object#price_object-recurring-usage_type +/// {@endtemplate} +@JsonEnum() +enum UsageType { + /// Automatically bills the quantity set when adding it to a subscription. + licensed, + + /// Bills based on usage. + metered, +} + +/// {@template stripe_price} +/// A partial Dart representation of the Price object from Stripe's API. +/// +/// See https://stripe.com/docs/api/prices/object. +/// {@endtemplate} +@JsonSerializable(createToJson: false) +class StripePrice { + /// {@macro stripe_price} + const StripePrice({ + required this.id, + required this.productId, + required this.currency, + required this.billingScheme, + this.unitAmount, + this.unitAmountDecimal, + this.tiers, + this.usageType, + this.metadata = const {}, + this.meterId, + this.nickname, + }); + + /// Converts a JSON object to a [StripePrice]. + factory StripePrice.fromJson(Map json) => + _$StripePriceFromJson(json); + + /// Unique identifier for this object, of the form "price_{base64_id}". + final String id; + + /// The ID of the product this price is associated with. + @JsonKey(name: 'product') + final String productId; + + /// Three-letter ISO currency code, in lowercase. + final String currency; + + /// The unit amount in cents to be charged, represented as a whole integer if + /// possible. Only set if billing_scheme=per_unit. + final int? unitAmount; + + /// Same as [unitAmount], but contains a decimal value with at most 12 decimal + /// places. + final Decimal? unitAmountDecimal; + + /// The tiers of the price, if it is a tiered price. + final List? tiers; + + /// One of "per_unit" or "tiered". Per unit pricing refers to the unit_price. + final BillingScheme billingScheme; + + /// Metadata associated with the price. + final Map metadata; + + /// The ID of the meter this price is attached to, if any. + /// This will only be present if this price has metered usage. A subscription + /// should have at most one item with metered usage. + @JsonKey(readValue: _readMeterId) + final String? meterId; + + /// The nickname of the price (set in Stripe's dashboard) + /// and displayed to the user via the Shorebird console. + final String? nickname; + + static Object? _readMeterId(Map json, String _) { + final recurring = json['recurring'] as Map?; + return recurring?['meter']; + } + + /// Whether this price is based on usage (metered) or the quantity in the + /// subscription (licensed). Will only be present if this price is attached + /// to a recurring subscription. + @JsonKey(readValue: _readUsageType) + final UsageType? usageType; + + static Object? _readUsageType(Map json, String _) { + final recurring = json['recurring'] as Map?; + return recurring?['usage_type']; + } +} + +/// Extension on [StripePrice] to interact with tiers. +extension StripePriceTiers on StripePrice { + /// Returns the tier for the given quantity. + /// + /// If [quantity] is greater than the highest tier's upper bound, returns the + /// first (and assumed only) tier with no upper bound. + StripePriceTier? tierForQuantity(int quantity) { + if (tiers == null) { + return null; + } + + final sortedBoundedTiers = tiers! + .where((tier) => tier.upTo != null) + .sortedBy((tier) => tier.upTo!); + + for (final tier in sortedBoundedTiers) { + if (tier.upTo! >= quantity) { + return tier; + } + } + + // If the quantity is greater than the highest bounded tier's upper bound, + // return the highest tier with a bound (we don't support unbounded tiers). + return sortedBoundedTiers.last; + } +} diff --git a/packages/stripe_api/lib/src/models/stripe_price.g.dart b/packages/stripe_api/lib/src/models/stripe_price.g.dart new file mode 100644 index 00000000..15d1981d --- /dev/null +++ b/packages/stripe_api/lib/src/models/stripe_price.g.dart @@ -0,0 +1,70 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: implicit_dynamic_parameter, require_trailing_commas, cast_nullable_to_non_nullable, lines_longer_than_80_chars, unnecessary_lambdas + +part of 'stripe_price.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +StripePrice _$StripePriceFromJson(Map json) => $checkedCreate( + 'StripePrice', + json, + ($checkedConvert) { + final val = StripePrice( + id: $checkedConvert('id', (v) => v as String), + productId: $checkedConvert('product', (v) => v as String), + currency: $checkedConvert('currency', (v) => v as String), + billingScheme: $checkedConvert( + 'billing_scheme', + (v) => $enumDecode(_$BillingSchemeEnumMap, v), + ), + unitAmount: $checkedConvert('unit_amount', (v) => (v as num?)?.toInt()), + unitAmountDecimal: $checkedConvert( + 'unit_amount_decimal', + (v) => v == null ? null : Decimal.fromJson(v as String), + ), + tiers: $checkedConvert( + 'tiers', + (v) => (v as List?) + ?.map((e) => StripePriceTier.fromJson(e as Map)) + .toList(), + ), + usageType: $checkedConvert( + 'usage_type', + (v) => $enumDecodeNullable(_$UsageTypeEnumMap, v), + readValue: StripePrice._readUsageType, + ), + metadata: $checkedConvert( + 'metadata', + (v) => v as Map? ?? const {}, + ), + meterId: $checkedConvert( + 'meter_id', + (v) => v as String?, + readValue: StripePrice._readMeterId, + ), + nickname: $checkedConvert('nickname', (v) => v as String?), + ); + return val; + }, + fieldKeyMap: const { + 'productId': 'product', + 'billingScheme': 'billing_scheme', + 'unitAmount': 'unit_amount', + 'unitAmountDecimal': 'unit_amount_decimal', + 'usageType': 'usage_type', + 'meterId': 'meter_id', + }, +); + +const _$BillingSchemeEnumMap = { + BillingScheme.perUnit: 'per_unit', + BillingScheme.tiered: 'tiered', +}; + +const _$UsageTypeEnumMap = { + UsageType.licensed: 'licensed', + UsageType.metered: 'metered', +}; diff --git a/packages/stripe_api/lib/src/models/stripe_price_tier.dart b/packages/stripe_api/lib/src/models/stripe_price_tier.dart new file mode 100644 index 00000000..61e8a868 --- /dev/null +++ b/packages/stripe_api/lib/src/models/stripe_price_tier.dart @@ -0,0 +1,42 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:stripe_api/src/models/models.dart'; + +part 'stripe_price_tier.g.dart'; + +/// {@template stripe_price} +/// A pricing tier for a [StripePrice]. +/// +/// See https://stripe.com/docs/api/prices/object#price_object-tiers. +/// {@endtemplate} +@JsonSerializable(createToJson: false) +class StripePriceTier { + /// {@macro stripe_price} + const StripePriceTier({ + required this.flatAmount, + required this.flatAmountDecimal, + required this.unitAmount, + required this.unitAmountDecimal, + required this.upTo, + }); + + /// Converts a JSON object to a [StripePriceTier]. + factory StripePriceTier.fromJson(Map json) => + _$StripePriceTierFromJson(json); + + /// Price for the entire tier. + final int? flatAmount; + + /// Same as [flatAmount], but contains a decimal value with at most 12 + /// decimal places. + final String? flatAmountDecimal; + + /// Per unit price for units relevant to the tier. + final int? unitAmount; + + /// Same as [unitAmount], but contains a decimal value with at most 12 + /// decimal places. + final String? unitAmountDecimal; + + /// Up to and including to this quantity will be contained in the tier. + final int? upTo; +} diff --git a/packages/stripe_api/lib/src/models/stripe_price_tier.g.dart b/packages/stripe_api/lib/src/models/stripe_price_tier.g.dart new file mode 100644 index 00000000..0ee277b4 --- /dev/null +++ b/packages/stripe_api/lib/src/models/stripe_price_tier.g.dart @@ -0,0 +1,39 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: implicit_dynamic_parameter, require_trailing_commas, cast_nullable_to_non_nullable, lines_longer_than_80_chars, unnecessary_lambdas + +part of 'stripe_price_tier.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +StripePriceTier _$StripePriceTierFromJson( + Map json, +) => $checkedCreate( + 'StripePriceTier', + json, + ($checkedConvert) { + final val = StripePriceTier( + flatAmount: $checkedConvert('flat_amount', (v) => (v as num?)?.toInt()), + flatAmountDecimal: $checkedConvert( + 'flat_amount_decimal', + (v) => v as String?, + ), + unitAmount: $checkedConvert('unit_amount', (v) => (v as num?)?.toInt()), + unitAmountDecimal: $checkedConvert( + 'unit_amount_decimal', + (v) => v as String?, + ), + upTo: $checkedConvert('up_to', (v) => (v as num?)?.toInt()), + ); + return val; + }, + fieldKeyMap: const { + 'flatAmount': 'flat_amount', + 'flatAmountDecimal': 'flat_amount_decimal', + 'unitAmount': 'unit_amount', + 'unitAmountDecimal': 'unit_amount_decimal', + 'upTo': 'up_to', + }, +); diff --git a/packages/stripe_api/lib/src/models/stripe_subscription.dart b/packages/stripe_api/lib/src/models/stripe_subscription.dart new file mode 100644 index 00000000..02955b4a --- /dev/null +++ b/packages/stripe_api/lib/src/models/stripe_subscription.dart @@ -0,0 +1,145 @@ +import 'package:collection/collection.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:stripe_api/src/converters/converters.dart'; +import 'package:stripe_api/stripe_api.dart'; + +part 'stripe_subscription.g.dart'; + +/// Possible states of a [StripeSubscription]. +/// +/// See https://stripe.com/docs/api/subscriptions/object#subscription_object-status. +enum StripeSubscriptionStatus { + /// Subscription is active and in good standing. + active, + + /// Subscription is unpaid and overdue. + @JsonValue('past_due') + pastDue, + + /// Subscription is unpaid -- no subsequent invoices will be attempted. + unpaid, + + /// Subscription is canceled and will not renew. + canceled, + + /// Subscription is incomplete if the initial payment attempt fails. + incomplete, + + /// Subscription is incomplete and has expired. + @JsonValue('incomplete_expired') + incompleteExpired, + + /// Subscription is on trial. + trialing, + + /// Subscription is paused. + paused, +} + +/// {@template stripe_subscription} +/// A partial Dart representation of the Subscription object from Stripe's API. +/// +/// See https://stripe.com/docs/api/subscriptions/object. +/// {@endtemplate} +@JsonSerializable(createToJson: false) +class StripeSubscription { + /// {@macro stripe_subscription} + StripeSubscription({ + required this.id, + required this.cancelAtPeriodEnd, + required this.currentPeriodEnd, + required this.currentPeriodStart, + required this.customer, + required this.startDate, + required this.status, + required this.items, + this.endedAt, + this.canceledAt, + this.trialStart, + this.trialEnd, + }); + + /// Converts a `Map` to a [StripeSubscription]. + factory StripeSubscription.fromJson(Map json) => + _$StripeSubscriptionFromJson(json); + + /// Unique identifier for the object. + final String id; + + /// If the subscription has been canceled with the at_period_end flag set to + /// true, cancel_at_period_end on the subscription will be true. You can use + /// this attribute to determine whether a subscription that has a status of + /// active is scheduled to be canceled at the end of the current period. + final bool cancelAtPeriodEnd; + + /// If the subscription has been canceled, the date of that cancellation. If + /// the subscription was canceled with cancel_at_period_end, canceled_at will + /// reflect the time of the most recent update request, not the end of the + /// subscription period when the subscription is automatically moved to a + /// canceled state. + @TimestampConverter() + final DateTime? canceledAt; + + /// End of the current period that the subscription has been invoiced for. At + /// the end of this period, a new invoice will be created. + @TimestampConverter() + final DateTime currentPeriodEnd; + + /// Start of the current period that the subscription has been invoiced for. + @TimestampConverter() + final DateTime currentPeriodStart; + + /// ID of the customer who owns the subscription. + final String customer; + + /// If the subscription has ended, the date the subscription ended. + @TimestampConverter() + final DateTime? endedAt; + + /// Date when the subscription was first created. The date might differ from + /// the created date due to backdating. + @TimestampConverter() + final DateTime startDate; + + /// Start of the trial period if this is a trial subscription. + @TimestampConverter() + final DateTime? trialStart; + + /// End of the trial period if this is a trial subscription. + @TimestampConverter() + final DateTime? trialEnd; + + /// The current state of this subscription. + /// + /// See https://stripe.com/docs/api/subscriptions/object#subscription_object-status. + final StripeSubscriptionStatus status; + + /// List of subscription items, each with an attached price. + @JsonKey(fromJson: _subscriptionItemsFromJson) + final List items; + + /// Whether this subscription is in an active or trialing state. + bool get isActiveOrTrial => + status == StripeSubscriptionStatus.active || + status == StripeSubscriptionStatus.trialing; + + /// Sum of all the subscription items' prices in cents. + int get totalCost => + items.map((item) => item.price.unitAmount).whereType().sum; + + /// Whether this subscription contains a the pay-as-you-go product as a line + /// item. We could also check the `usage_type` of the Stripe plan object, but + /// this works for now. + bool get hasMeteredBilling => + items.any((item) => item.price.usageType == UsageType.metered); +} + +List _subscriptionItemsFromJson( + Map? json, +) { + final data = json?['data'] as List? ?? []; + return data + .whereType>() + .map(StripeSubscriptionItem.fromJson) + .toList(); +} diff --git a/packages/stripe_api/lib/src/models/stripe_subscription.g.dart b/packages/stripe_api/lib/src/models/stripe_subscription.g.dart new file mode 100644 index 00000000..d373424a --- /dev/null +++ b/packages/stripe_api/lib/src/models/stripe_subscription.g.dart @@ -0,0 +1,100 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: implicit_dynamic_parameter, require_trailing_commas, cast_nullable_to_non_nullable, lines_longer_than_80_chars, unnecessary_lambdas + +part of 'stripe_subscription.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +StripeSubscription _$StripeSubscriptionFromJson(Map json) => + $checkedCreate( + 'StripeSubscription', + json, + ($checkedConvert) { + final val = StripeSubscription( + id: $checkedConvert('id', (v) => v as String), + cancelAtPeriodEnd: $checkedConvert( + 'cancel_at_period_end', + (v) => v as bool, + ), + currentPeriodEnd: $checkedConvert( + 'current_period_end', + (v) => const TimestampConverter().fromJson((v as num).toInt()), + ), + currentPeriodStart: $checkedConvert( + 'current_period_start', + (v) => const TimestampConverter().fromJson((v as num).toInt()), + ), + customer: $checkedConvert('customer', (v) => v as String), + startDate: $checkedConvert( + 'start_date', + (v) => const TimestampConverter().fromJson((v as num).toInt()), + ), + status: $checkedConvert( + 'status', + (v) => $enumDecode(_$StripeSubscriptionStatusEnumMap, v), + ), + items: $checkedConvert( + 'items', + (v) => _subscriptionItemsFromJson(v as Map?), + ), + endedAt: $checkedConvert( + 'ended_at', + (v) => _$JsonConverterFromJson( + v, + const TimestampConverter().fromJson, + ), + ), + canceledAt: $checkedConvert( + 'canceled_at', + (v) => _$JsonConverterFromJson( + v, + const TimestampConverter().fromJson, + ), + ), + trialStart: $checkedConvert( + 'trial_start', + (v) => _$JsonConverterFromJson( + v, + const TimestampConverter().fromJson, + ), + ), + trialEnd: $checkedConvert( + 'trial_end', + (v) => _$JsonConverterFromJson( + v, + const TimestampConverter().fromJson, + ), + ), + ); + return val; + }, + fieldKeyMap: const { + 'cancelAtPeriodEnd': 'cancel_at_period_end', + 'currentPeriodEnd': 'current_period_end', + 'currentPeriodStart': 'current_period_start', + 'startDate': 'start_date', + 'endedAt': 'ended_at', + 'canceledAt': 'canceled_at', + 'trialStart': 'trial_start', + 'trialEnd': 'trial_end', + }, + ); + +const _$StripeSubscriptionStatusEnumMap = { + StripeSubscriptionStatus.active: 'active', + StripeSubscriptionStatus.pastDue: 'past_due', + StripeSubscriptionStatus.unpaid: 'unpaid', + StripeSubscriptionStatus.canceled: 'canceled', + StripeSubscriptionStatus.incomplete: 'incomplete', + StripeSubscriptionStatus.incompleteExpired: 'incomplete_expired', + StripeSubscriptionStatus.trialing: 'trialing', + StripeSubscriptionStatus.paused: 'paused', +}; + +Value? _$JsonConverterFromJson( + Object? json, + Value? Function(Json json) fromJson, +) => json == null ? null : fromJson(json as Json); diff --git a/packages/stripe_api/lib/src/models/stripe_subscription_item.dart b/packages/stripe_api/lib/src/models/stripe_subscription_item.dart new file mode 100644 index 00000000..e6475aaa --- /dev/null +++ b/packages/stripe_api/lib/src/models/stripe_subscription_item.dart @@ -0,0 +1,34 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:stripe_api/stripe_api.dart'; + +part 'stripe_subscription_item.g.dart'; + +/// {@template stripe_subscription_item} +/// A partial Dart representation of the SubscriptionItem object from Stripe's +/// API. +/// +/// See https://stripe.com/docs/api/subscription_items/object. +/// {@endtemplate} +@JsonSerializable(createToJson: false) +class StripeSubscriptionItem { + /// {@macro stripe_subscription_item} + const StripeSubscriptionItem({ + required this.id, + required this.price, + this.quantity, + }); + + /// Converts a JSON object to a [StripeSubscriptionItem]. + factory StripeSubscriptionItem.fromJson(Map json) => + _$StripeSubscriptionItemFromJson(json); + + /// Unique identifier for the object, of the form "si_{base64_id}". + final String id; + + /// The price the customer is subscribed to. + final StripePrice price; + + /// The quantity of the plan to which the customer should be subscribed. This + /// will be null if the associated plan has a `metered` usage type. + final int? quantity; +} diff --git a/packages/stripe_api/lib/src/models/stripe_subscription_item.g.dart b/packages/stripe_api/lib/src/models/stripe_subscription_item.g.dart new file mode 100644 index 00000000..b64178e8 --- /dev/null +++ b/packages/stripe_api/lib/src/models/stripe_subscription_item.g.dart @@ -0,0 +1,23 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: implicit_dynamic_parameter, require_trailing_commas, cast_nullable_to_non_nullable, lines_longer_than_80_chars, unnecessary_lambdas + +part of 'stripe_subscription_item.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +StripeSubscriptionItem _$StripeSubscriptionItemFromJson( + Map json, +) => $checkedCreate('StripeSubscriptionItem', json, ($checkedConvert) { + final val = StripeSubscriptionItem( + id: $checkedConvert('id', (v) => v as String), + price: $checkedConvert( + 'price', + (v) => StripePrice.fromJson(v as Map), + ), + quantity: $checkedConvert('quantity', (v) => (v as num?)?.toInt()), + ); + return val; +}); diff --git a/packages/stripe_api/lib/src/stripe_api.dart b/packages/stripe_api/lib/src/stripe_api.dart new file mode 100644 index 00000000..b2835b38 --- /dev/null +++ b/packages/stripe_api/lib/src/stripe_api.dart @@ -0,0 +1,174 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:stripe_api/src/models/internal/internal.dart'; +import 'package:stripe_api/stripe_api.dart'; + +/// {@template stripe_api} +/// Allows interaction with the Stripe API. +/// {@endtemplate} +class StripeApi { + /// {@macro stripe_api} + StripeApi({required String secretKey, http.Client? client}) + : _client = client ?? http.Client(), + _secretKey = secretKey; + + final http.Client _client; + final String _secretKey; + + /// Fetches all active and trial subscriptions for [customerId]. + Future> fetchActiveOrTrialSubscriptions({ + required String customerId, + }) async { + final customer = await fetchCustomer(customerId: customerId); + return (customer.subscriptions ?? []) + .where((subscription) => subscription.isActiveOrTrial) + .toList(); + } + + /// Retrieves a [StripeCustomer] with the given [customerId]. + Future fetchCustomer({required String customerId}) async { + final uri = _stripeUri( + path: 'customers/$customerId', + queryParameters: {'expand[]': 'subscriptions'}, + ); + + final response = await _client.get(uri, headers: _authHeaders); + if (response.statusCode != HttpStatus.ok) { + throw Exception('Failed to retrieve customer with id $customerId'); + } + + return StripeCustomer.fromJson( + jsonDecode(response.body) as Map, + ); + } + + /// Retrieves a [StripeSubscription] with the given [subscriptionId]. + Future fetchSubscription({ + required String subscriptionId, + }) async { + final uri = _stripeUri( + path: 'subscriptions/$subscriptionId', + queryParameters: {'expand[]': 'items.data.price.tiers'}, + ); + + final response = await _client.get(uri, headers: _authHeaders); + if (response.statusCode != HttpStatus.ok) { + throw Exception( + 'Failed to retrieve subscription with id $subscriptionId', + ); + } + + return StripeSubscription.fromJson( + jsonDecode(response.body) as Map, + ); + } + + /// Creates a new meter event for the given [customerId]. + /// See https://docs.stripe.com/api/billing/meter-event + Future createMeterEvent({ + required String customerId, + required String eventName, + required int value, + int? timestamp, + }) async { + final uri = _stripeUri(path: 'billing/meter_events'); + final response = await _client.post( + uri, + headers: _authHeaders, + body: { + 'event_name': eventName, + 'payload[value]': '$value', + 'payload[stripe_customer_id]': customerId, + if (timestamp != null) 'timestamp': '$timestamp', + }, + ); + + if (response.statusCode != HttpStatus.ok) { + throw Exception(''' +Failed to report $value for customer $customerId. Error: +${response.body} +'''); + } + } + + /// Fetches all meter event summaries for the given [meterId] and [customerId] + /// within the given [startTimestamp] and [endTimestamp]. + Future> getMeterEventSummaries({ + required String meterId, + required String customerId, + required int startTimestamp, + required int endTimestamp, + }) async { + return _fetchAllPages( + path: 'billing/meters/$meterId/event_summaries', + queryParameters: { + 'customer': customerId, + 'start_time': '$startTimestamp', + 'end_time': '$endTimestamp', + }, + fromJson: StripeMeterEventSummary.fromJson, + getId: (e) => e.id, + ); + } + + /// Fetches all pages of objects from a paginated endpoint. + Future> _fetchAllPages({ + required String path, + required T Function(Map) fromJson, + required String Function(T) getId, + Map queryParameters = const {}, + }) async { + final pagedObjects = []; + + while (true) { + final uri = _stripeUri( + path: path, + queryParameters: queryParameters + ..addAll({ + // 100 is the max, as per https://docs.stripe.com/api/pagination + 'limit': '100', + if (pagedObjects.isNotEmpty) + 'starting_after': getId(pagedObjects.last), + }), + ); + + final response = await _client.get(uri, headers: _authHeaders); + if (response.statusCode != HttpStatus.ok) { + throw Exception(''' +Failed to get paged response from $path with params $queryParameters. Error: +${response.body} +'''); + } + + final pagedResponse = PagedResponse.fromJson( + jsonDecode(response.body) as Map, + ); + + pagedObjects.addAll( + pagedResponse.data.whereType>().map(fromJson), + ); + + if (!pagedResponse.hasMore) { + break; + } + } + + return pagedObjects; + } + + late final Map _authHeaders = { + HttpHeaders.authorizationHeader: 'Bearer $_secretKey', + }; + + Uri _stripeUri({ + required String path, + Map? queryParameters, + }) => Uri( + scheme: 'https', + host: 'api.stripe.com', + path: '/v1/$path', + queryParameters: queryParameters, + ); +} diff --git a/packages/stripe_api/lib/stripe_api.dart b/packages/stripe_api/lib/stripe_api.dart new file mode 100644 index 00000000..f557cb47 --- /dev/null +++ b/packages/stripe_api/lib/stripe_api.dart @@ -0,0 +1,2 @@ +export 'src/models/models.dart'; +export 'src/stripe_api.dart'; diff --git a/packages/stripe_api/pubspec.yaml b/packages/stripe_api/pubspec.yaml new file mode 100644 index 00000000..610aa38f --- /dev/null +++ b/packages/stripe_api/pubspec.yaml @@ -0,0 +1,20 @@ +name: stripe_api +description: A Dart library for interacting with the Stripe API. +version: 0.1.0+1 +publish_to: none +resolution: workspace + +environment: + sdk: ">=3.8.0 <4.0.0" + +dependencies: + collection: ^1.17.1 + decimal: ^3.0.2 + http: ^1.0.0 + json_annotation: ^4.8.1 + +dev_dependencies: + build_runner: ^2.0.0 + json_serializable: ^6.6.1 + mocktail: ^1.0.0 + test: ^1.19.2 diff --git a/packages/stripe_api/test/fixtures/stripe/json/checkout_session_completed_event.json b/packages/stripe_api/test/fixtures/stripe/json/checkout_session_completed_event.json new file mode 100644 index 00000000..65fb4705 --- /dev/null +++ b/packages/stripe_api/test/fixtures/stripe/json/checkout_session_completed_event.json @@ -0,0 +1,95 @@ +{ + "id": "evt_1MxtSfHSA9cXarIcNYdaeJq0", + "object": "event", + "api_version": "2022-11-15", + "created": 1681743321, + "data": { + "object": { + "id": "cs_test_a1owXzmNpczllTlcPSVKUAQw5I9baSNnqsn8FgFaGZ8g0Vj98nJ1C56Pjq", + "object": "checkout.session", + "after_expiration": null, + "allow_promotion_codes": false, + "amount_subtotal": 2000, + "amount_total": 2000, + "automatic_tax": { + "enabled": false, + "status": null + }, + "billing_address_collection": "auto", + "cancel_url": "https://stripe.com", + "client_reference_id": null, + "consent": null, + "consent_collection": null, + "created": 1681743304, + "currency": "usd", + "currency_conversion": null, + "custom_fields": [], + "custom_text": { + "shipping_address": null, + "submit": null + }, + "customer": "cus_123", + "customer_creation": "if_required", + "customer_details": { + "address": { + "city": null, + "country": "US", + "line1": null, + "line2": null, + "postal_code": "11205", + "state": null + }, + "email": "tester@shorebird.dev", + "name": "asfd asdf", + "phone": null, + "tax_exempt": "none", + "tax_ids": [] + }, + "customer_email": null, + "expires_at": 1681829704, + "invoice": "in_1MxtScHSA9cXarIcR4Zj5qWB", + "invoice_creation": null, + "livemode": false, + "locale": "auto", + "metadata": { + "shorebird_email": "tester@shorebird.dev" + }, + "mode": "subscription", + "payment_intent": null, + "payment_link": "plink_1MxtSFHSA9cXarIcDGq40FNC", + "payment_method_collection": "always", + "payment_method_options": null, + "payment_method_types": [ + "card", + "cashapp" + ], + "payment_status": "paid", + "phone_number_collection": { + "enabled": false + }, + "recovered_from": null, + "setup_intent": null, + "shipping_address_collection": null, + "shipping_cost": null, + "shipping_details": null, + "shipping_options": [], + "status": "complete", + "submit_type": "auto", + "subscription": "sub_1MxtScHSA9cXarIcZeVodbUq", + "success_url": "https://stripe.com", + "total_details": { + "amount_discount": 0, + "amount_shipping": 0, + "amount_tax": 0 + }, + "url": null + } + }, + "livemode": false, + "pending_webhooks": 4, + "request": { + "id": null, + "idempotency_key": null + }, + "type": "checkout.session.completed" +} diff --git a/packages/stripe_api/test/fixtures/stripe/json/create_payment_link.json b/packages/stripe_api/test/fixtures/stripe/json/create_payment_link.json new file mode 100644 index 00000000..07df58a0 --- /dev/null +++ b/packages/stripe_api/test/fixtures/stripe/json/create_payment_link.json @@ -0,0 +1,45 @@ +{ + "id": "plink_1MxYzdHSA9cXarIcJ3M6OGU1", + "object": "payment_link", + "active": true, + "after_completion": { + "hosted_confirmation": { + "custom_message": null + }, + "type": "hosted_confirmation" + }, + "allow_promotion_codes": false, + "application_fee_amount": null, + "application_fee_percent": null, + "automatic_tax": { + "enabled": false + }, + "billing_address_collection": "auto", + "consent_collection": null, + "currency": "usd", + "custom_fields": [], + "custom_text": { + "shipping_address": null, + "submit": null + }, + "customer_creation": "always", + "invoice_creation": null, + "livemode": false, + "metadata": {}, + "on_behalf_of": null, + "payment_intent_data": null, + "payment_method_collection": "always", + "payment_method_types": null, + "phone_number_collection": { + "enabled": false + }, + "shipping_address_collection": null, + "shipping_options": [], + "submit_type": "auto", + "subscription_data": null, + "tax_id_collection": { + "enabled": false + }, + "transfer_data": null, + "url": "https://buy.stripe.com/test_3cs4j2dDV05KbFSdQQ" +} diff --git a/packages/stripe_api/test/fixtures/stripe/json/customer.json b/packages/stripe_api/test/fixtures/stripe/json/customer.json new file mode 100644 index 00000000..d98eab88 --- /dev/null +++ b/packages/stripe_api/test/fixtures/stripe/json/customer.json @@ -0,0 +1,29 @@ +{ + "id": "cus_123", + "object": "customer", + "address": null, + "balance": 0, + "created": 1681227926, + "currency": "usd", + "default_source": null, + "delinquent": false, + "description": "(created by Stripe CLI)", + "discount": null, + "email": "test@shorebird.dev", + "invoice_prefix": "C97CE2FD", + "invoice_settings": { + "custom_fields": null, + "default_payment_method": "pm_1MvjNqHSA9cXarIcD9cZP6XJ", + "footer": null, + "rendering_options": null + }, + "livemode": false, + "metadata": {}, + "name": "Jane Doe", + "next_invoice_sequence": 2, + "phone": null, + "preferred_locales": [], + "shipping": null, + "tax_exempt": "none", + "test_clock": null + } \ No newline at end of file diff --git a/packages/stripe_api/test/fixtures/stripe/json/customer_search_multiple_results.json b/packages/stripe_api/test/fixtures/stripe/json/customer_search_multiple_results.json new file mode 100644 index 00000000..df52306f --- /dev/null +++ b/packages/stripe_api/test/fixtures/stripe/json/customer_search_multiple_results.json @@ -0,0 +1,69 @@ +{ + "object": "search_result", + "url": "/v1/customers/search", + "has_more": false, + "data": [ + { + "id": "cus_NgnmZaVgPxXxBP", + "object": "customer", + "address": null, + "balance": 0, + "created": 1681154091, + "currency": "usd", + "default_source": "card_1MvQAxHSA9cXarIcB4B6BS4D", + "delinquent": false, + "description": "(created by Stripe CLI)", + "discount": null, + "email": "test@shorebird.dev", + "invoice_prefix": "28F708F6", + "invoice_settings": { + "custom_fields": null, + "default_payment_method": null, + "footer": null, + "rendering_options": null + }, + "livemode": false, + "metadata": { + "foo": "bar" + }, + "name": "fakename", + "next_invoice_sequence": 1, + "phone": null, + "preferred_locales": [], + "shipping": null, + "tax_exempt": "none", + "test_clock": null + }, + { + "id": "cus_NgnmZaVgPxXxBA", + "object": "customer", + "address": null, + "balance": 0, + "created": 1681154091, + "currency": "usd", + "default_source": "card_1MvQAxHSA9cXarIcB4B6BS4C", + "delinquent": false, + "description": "(created by Stripe CLI)", + "discount": null, + "email": "test@shorebird.dev", + "invoice_prefix": "28F708F6", + "invoice_settings": { + "custom_fields": null, + "default_payment_method": null, + "footer": null, + "rendering_options": null + }, + "livemode": false, + "metadata": { + "foo": "bar" + }, + "name": "fakename", + "next_invoice_sequence": 1, + "phone": null, + "preferred_locales": [], + "shipping": null, + "tax_exempt": "none", + "test_clock": null + } + ] +} diff --git a/packages/stripe_api/test/fixtures/stripe/json/customer_search_no_results.json b/packages/stripe_api/test/fixtures/stripe/json/customer_search_no_results.json new file mode 100644 index 00000000..2405aee9 --- /dev/null +++ b/packages/stripe_api/test/fixtures/stripe/json/customer_search_no_results.json @@ -0,0 +1,6 @@ +{ + "object": "search_result", + "url": "/v1/customers/search", + "has_more": false, + "data": [] +} diff --git a/packages/stripe_api/test/fixtures/stripe/json/customer_search_one_result.json b/packages/stripe_api/test/fixtures/stripe/json/customer_search_one_result.json new file mode 100644 index 00000000..12c13313 --- /dev/null +++ b/packages/stripe_api/test/fixtures/stripe/json/customer_search_one_result.json @@ -0,0 +1,38 @@ +{ + "object": "search_result", + "url": "/v1/customers/search", + "has_more": false, + "data": [ + { + "id": "cus_NgnmZaVgPxXxBP", + "object": "customer", + "address": null, + "balance": 0, + "created": 1681154091, + "currency": "usd", + "default_source": "card_1MvQAxHSA9cXarIcB4B6BS4D", + "delinquent": false, + "description": "(created by Stripe CLI)", + "discount": null, + "email": "test@shorebird.dev", + "invoice_prefix": "28F708F6", + "invoice_settings": { + "custom_fields": null, + "default_payment_method": null, + "footer": null, + "rendering_options": null + }, + "livemode": false, + "metadata": { + "foo": "bar" + }, + "name": "fakename", + "next_invoice_sequence": 1, + "phone": null, + "preferred_locales": [], + "shipping": null, + "tax_exempt": "none", + "test_clock": null + } + ] +} diff --git a/packages/stripe_api/test/fixtures/stripe/json/customer_search_one_result_with_expanded_subscriptions.json b/packages/stripe_api/test/fixtures/stripe/json/customer_search_one_result_with_expanded_subscriptions.json new file mode 100644 index 00000000..d76ccb73 --- /dev/null +++ b/packages/stripe_api/test/fixtures/stripe/json/customer_search_one_result_with_expanded_subscriptions.json @@ -0,0 +1,193 @@ +{ + "object": "search_result", + "data": [ + { + "id": "cus_123", + "object": "customer", + "address": null, + "balance": 0, + "created": 1681154091, + "currency": "usd", + "default_source": "card_1MvQAxHSA9cXarIcB4B6BS4D", + "delinquent": false, + "description": "(created by Stripe CLI)", + "discount": null, + "email": "test@shorebird.dev", + "invoice_prefix": "12A345B6", + "invoice_settings": { + "custom_fields": null, + "default_payment_method": null, + "footer": null, + "rendering_options": null + }, + "livemode": false, + "metadata": { + "foo": "bar" + }, + "name": "Jane Doe", + "next_invoice_sequence": 1, + "phone": null, + "preferred_locales": [], + "shipping": null, + "subscriptions": { + "object": "list", + "data": [ + { + "id": "sub_1Mo9r2HSA9cXarIcVgf2GQt4", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "enabled": true + }, + "billing_cycle_anchor": 1679423056, + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "cancellation_details": { + "comment": null, + "feedback": null, + "reason": null + }, + "collection_method": "charge_automatically", + "created": 1679423056, + "currency": "usd", + "current_period_end": 1682101456, + "current_period_start": 1679423056, + "customer": "cus_123", + "days_until_due": null, + "default_payment_method": "card_1MvQAxHSA9cXarIcB4B6BS4D", + "default_source": null, + "default_tax_rates": [], + "description": null, + "discount": null, + "ended_at": null, + "items": { + "object": "list", + "data": [ + { + "id": "si_1MvQAxHSA9cXarIcB4B6BS4D", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1679423057, + "metadata": {}, + "plan": { + "id": "price_1MvQAxHSA9cXarIcB4B6BS4D", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 2000, + "amount_decimal": "2000", + "billing_scheme": "per_unit", + "created": 1678492194, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": true, + "metadata": {}, + "nickname": null, + "product": "prod_1MvQAxHSA9cXarIcB4B6BS4D", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1MvQAxHSA9cXarIcB4B6BS4D", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1678492194, + "currency": "usd", + "custom_unit_amount": null, + "livemode": true, + "lookup_key": null, + "metadata": {}, + "nickname": null, + "product": "prod_1MvQAxHSA9cXarIcB4B6BS4D", + "recurring": { + "aggregate_usage": null, + "interval": "month", + "interval_count": 1, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "exclusive", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 2000, + "unit_amount_decimal": "2000" + }, + "quantity": 1, + "subscription": "sub_1MvQAxHSA9cXarIcB4B6BS4D", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1MvQAxHSA9cXarIcB4B6BS4D" + }, + "latest_invoice": "in_1MvQAxHSA9cXarIcB4B6BS4D", + "livemode": true, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": null, + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "plan": { + "id": "price_1MvQAxHSA9cXarIcB4B6BS4D", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 2000, + "amount_decimal": "2000", + "billing_scheme": "per_unit", + "created": 1678492194, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": true, + "metadata": {}, + "nickname": null, + "product": "prod_1MvQAxHSA9cXarIcB4B6BS4D", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start_date": 1679423056, + "status": "active", + "test_clock": null, + "transfer_data": null, + "trial_end": null, + "trial_settings": { + "end_behavior": { + "missing_payment_method": "cancel" + } + }, + "trial_start": null + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/customers/cus_123/subscriptions" + }, + "tax_exempt": "none", + "test_clock": null + } + ], + "has_more": false, + "next_page": null, + "url": "/v1/customers/search" +} diff --git a/packages/stripe_api/test/fixtures/stripe/json/customer_with_empty_subscriptions.json b/packages/stripe_api/test/fixtures/stripe/json/customer_with_empty_subscriptions.json new file mode 100644 index 00000000..a45c1ea2 --- /dev/null +++ b/packages/stripe_api/test/fixtures/stripe/json/customer_with_empty_subscriptions.json @@ -0,0 +1,36 @@ +{ + "id": "cus_123", + "object": "customer", + "address": null, + "balance": 0, + "created": 1682026744, + "currency": null, + "default_source": null, + "delinquent": false, + "description": null, + "discount": null, + "email": "tester@shorebird.dev", + "invoice_prefix": "6B792ED7", + "invoice_settings": { + "custom_fields": null, + "default_payment_method": null, + "footer": null, + "rendering_options": null + }, + "livemode": false, + "metadata": {}, + "name": "Bryan", + "next_invoice_sequence": 1, + "phone": null, + "preferred_locales": [], + "shipping": null, + "subscriptions": { + "object": "list", + "data": [], + "has_more": false, + "total_count": 0, + "url": "/v1/customers/cus_123/subscriptions" + }, + "tax_exempt": "none", + "test_clock": null +} diff --git a/packages/stripe_api/test/fixtures/stripe/json/customer_with_inactive_subscription.json b/packages/stripe_api/test/fixtures/stripe/json/customer_with_inactive_subscription.json new file mode 100644 index 00000000..eff06d81 --- /dev/null +++ b/packages/stripe_api/test/fixtures/stripe/json/customer_with_inactive_subscription.json @@ -0,0 +1,183 @@ +{ + "id": "cus_123", + "object": "customer", + "address": null, + "balance": 0, + "created": 1682026744, + "currency": "usd", + "default_source": "card_1Mz5EAHSA9cXarIcCcBvlE8S", + "delinquent": false, + "description": null, + "discount": null, + "email": "tester@shorebird.dev", + "invoice_prefix": "6B792ED7", + "invoice_settings": { + "custom_fields": null, + "default_payment_method": null, + "footer": null, + "rendering_options": null + }, + "livemode": false, + "metadata": {}, + "name": "Bryan", + "next_invoice_sequence": 2, + "phone": null, + "preferred_locales": [], + "shipping": null, + "subscriptions": { + "object": "list", + "data": [ + { + "id": "sub_1Mz5EJHSA9cXarIcEtgCXEFX", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "enabled": false + }, + "billing_cycle_anchor": 1682026887, + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "cancellation_details": { + "comment": null, + "feedback": null, + "reason": null + }, + "collection_method": "charge_automatically", + "created": 1682026887, + "currency": "usd", + "current_period_end": 1684618887, + "current_period_start": 1682026887, + "customer": "cus_123", + "days_until_due": null, + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discount": null, + "ended_at": null, + "items": { + "object": "list", + "data": [ + { + "id": "si_NkaPreHeOuPEvY", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1682026888, + "metadata": {}, + "plan": { + "id": "price_1MxZCLHSA9cXarIckOQjr3VY", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 2000, + "amount_decimal": "2000", + "billing_scheme": "per_unit", + "created": 1681665429, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "nickname": null, + "product": "prod_Nj1EDNMwkkS0xc", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1MxZCLHSA9cXarIckOQjr3VY", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1681665429, + "currency": "usd", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": null, + "metadata": {}, + "nickname": null, + "product": "prod_Nj1EDNMwkkS0xc", + "recurring": { + "aggregate_usage": null, + "interval": "month", + "interval_count": 1, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "exclusive", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 2000, + "unit_amount_decimal": "2000" + }, + "quantity": 1, + "subscription": "sub_1Mz5EJHSA9cXarIcEtgCXEFX", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1Mz5EJHSA9cXarIcEtgCXEFX" + }, + "latest_invoice": "in_1Mz5EJHSA9cXarIcqtI5chbK", + "livemode": false, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": null, + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "plan": { + "id": "price_1MxZCLHSA9cXarIckOQjr3VY", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 2000, + "amount_decimal": "2000", + "billing_scheme": "per_unit", + "created": 1681665429, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "nickname": null, + "product": "prod_Nj1EDNMwkkS0xc", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start_date": 1682026887, + "status": "canceled", + "test_clock": null, + "transfer_data": null, + "trial_end": null, + "trial_settings": { + "end_behavior": { + "missing_payment_method": "create_invoice" + } + }, + "trial_start": null + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/customers/cus_123/subscriptions" + }, + "tax_exempt": "none", + "test_clock": null +} diff --git a/packages/stripe_api/test/fixtures/stripe/json/customer_with_subscription.json b/packages/stripe_api/test/fixtures/stripe/json/customer_with_subscription.json new file mode 100644 index 00000000..20eb51db --- /dev/null +++ b/packages/stripe_api/test/fixtures/stripe/json/customer_with_subscription.json @@ -0,0 +1,183 @@ +{ + "id": "cus_123", + "object": "customer", + "address": null, + "balance": 0, + "created": 1682026744, + "currency": "usd", + "default_source": "card_1Mz5EAHSA9cXarIcCcBvlE8S", + "delinquent": false, + "description": null, + "discount": null, + "email": "tester@shorebird.dev", + "invoice_prefix": "6B792ED7", + "invoice_settings": { + "custom_fields": null, + "default_payment_method": null, + "footer": null, + "rendering_options": null + }, + "livemode": false, + "metadata": {}, + "name": "Bryan", + "next_invoice_sequence": 2, + "phone": null, + "preferred_locales": [], + "shipping": null, + "subscriptions": { + "object": "list", + "data": [ + { + "id": "sub_1Mz5EJHSA9cXarIcEtgCXEFX", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "enabled": false + }, + "billing_cycle_anchor": 1682026887, + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "cancellation_details": { + "comment": null, + "feedback": null, + "reason": null + }, + "collection_method": "charge_automatically", + "created": 1682026887, + "currency": "usd", + "current_period_end": 1684618887, + "current_period_start": 1682026887, + "customer": "cus_123", + "days_until_due": null, + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discount": null, + "ended_at": null, + "items": { + "object": "list", + "data": [ + { + "id": "si_NkaPreHeOuPEvY", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1682026888, + "metadata": {}, + "plan": { + "id": "price_1MxZCLHSA9cXarIckOQjr3VY", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 2000, + "amount_decimal": "2000", + "billing_scheme": "per_unit", + "created": 1681665429, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "nickname": null, + "product": "prod_123", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1MxZCLHSA9cXarIckOQjr3VY", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1681665429, + "currency": "usd", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": null, + "metadata": {}, + "nickname": null, + "product": "prod_123", + "recurring": { + "aggregate_usage": null, + "interval": "month", + "interval_count": 1, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "exclusive", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 2000, + "unit_amount_decimal": "2000" + }, + "quantity": 1, + "subscription": "sub_1Mz5EJHSA9cXarIcEtgCXEFX", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1Mz5EJHSA9cXarIcEtgCXEFX" + }, + "latest_invoice": "in_1Mz5EJHSA9cXarIcqtI5chbK", + "livemode": false, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": null, + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "plan": { + "id": "price_1MxZCLHSA9cXarIckOQjr3VY", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 2000, + "amount_decimal": "2000", + "billing_scheme": "per_unit", + "created": 1681665429, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "nickname": null, + "product": "prod_123", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start_date": 1682026887, + "status": "active", + "test_clock": null, + "transfer_data": null, + "trial_end": null, + "trial_settings": { + "end_behavior": { + "missing_payment_method": "create_invoice" + } + }, + "trial_start": null + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/customers/cus_123/subscriptions" + }, + "tax_exempt": "none", + "test_clock": null +} diff --git a/packages/stripe_api/test/fixtures/stripe/json/meter_event_summaries_no_more_pages.json b/packages/stripe_api/test/fixtures/stripe/json/meter_event_summaries_no_more_pages.json new file mode 100644 index 00000000..bca089ff --- /dev/null +++ b/packages/stripe_api/test/fixtures/stripe/json/meter_event_summaries_no_more_pages.json @@ -0,0 +1,16 @@ +{ + "object": "list", + "data": [ + { + "id": "mtrusg_test_6041HSA9cXarIc6U76ce6L359f4ft60m5Xl3ox5sv7bt6bJ5bx5Y459D4Xt2E17ko6M86kt7kV3bl5QJ3U87PA60g6kp3Dn3kL7Gu3HU5Xl3ox5sv7bt6bY4p52Dr7od3Hc71E6go4od4LJ6cC6UM4t17Lc3Ta6ky3fx2D33fu3LI3oA3Cy3Cy3Cy", + "object": "billing.meter_event_summary", + "aggregated_value": 610000.0, + "end_time": 1722495600, + "livemode": false, + "meter": "mtr_test_61Qs4Xo4ZBeo0c8Em41HSA9cXarIc0Ho", + "start_time": 1722492000 + } + ], + "has_more": false, + "url": "/v1/billing/meters/:id/event_summaries" +} diff --git a/packages/stripe_api/test/fixtures/stripe/json/meter_event_summaries_with_more_pages.json b/packages/stripe_api/test/fixtures/stripe/json/meter_event_summaries_with_more_pages.json new file mode 100644 index 00000000..0117c591 --- /dev/null +++ b/packages/stripe_api/test/fixtures/stripe/json/meter_event_summaries_with_more_pages.json @@ -0,0 +1,16 @@ +{ + "object": "list", + "data": [ + { + "id": "mtrusg_test_6041HSA9cXarIc6U76ce6L359f4ft60m5Xl3ox5sv7bt6bJ5bx5Y459D4Xt2E17ko6M86kt7kV3bl5QJ3U87PA60g6kp3Dn3kL7Gu3HU5Xl3ox5sv7bt6bY4p52Dr7od3Hc71E6go4od4LJ6cC6UM4t17Lc3Ta6ky3fx2D33fu3LI3oD3bk3Cy3Cy", + "object": "billing.meter_event_summary", + "aggregated_value": 610000.0, + "end_time": 1722499200, + "livemode": false, + "meter": "mtr_test_61Qs4Xo4ZBeo0c8Em41HSA9cXarIc0Ho", + "start_time": 1722495600 + } + ], + "has_more": true, + "url": "/v1/billing/meters/:id/event_summaries" +} diff --git a/packages/stripe_api/test/fixtures/stripe/json/pay_as_you_go_subscription_item.json b/packages/stripe_api/test/fixtures/stripe/json/pay_as_you_go_subscription_item.json new file mode 100644 index 00000000..05848b15 --- /dev/null +++ b/packages/stripe_api/test/fixtures/stripe/json/pay_as_you_go_subscription_item.json @@ -0,0 +1,60 @@ +{ + "id": "si_QdM8G4ozxTdW00", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1723258390, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1Pm4YJHSA9cXarIcpDllPtvw", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": null, + "amount_decimal": "0.04", + "billing_scheme": "per_unit", + "created": 1723255027, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": "mtr_test_61QvSUDTnLya5cdwG41HSA9cXarIc144", + "nickname": null, + "product": "prod_QdLE0bb1qFBFMv", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "metered" + }, + "price": { + "id": "price_1Pm4YJHSA9cXarIcpDllPtvw", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1723255027, + "currency": "usd", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": null, + "metadata": {}, + "nickname": null, + "product": "prod_QdLE0bb1qFBFMv", + "recurring": { + "aggregate_usage": null, + "interval": "month", + "interval_count": 1, + "meter": "mtr_test_61QvSUDTnLya5cdwG41HSA9cXarIc144", + "trial_period_days": null, + "usage_type": "metered" + }, + "tax_behavior": "exclusive", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": null, + "unit_amount_decimal": "0.04" + }, + "subscription": "sub_1Pm5QYHSA9cXarIcRDsMStDo", + "tax_rates": [] +} \ No newline at end of file diff --git a/packages/stripe_api/test/fixtures/stripe/json/platform_access_subscription_item.json b/packages/stripe_api/test/fixtures/stripe/json/platform_access_subscription_item.json new file mode 100644 index 00000000..ea101f22 --- /dev/null +++ b/packages/stripe_api/test/fixtures/stripe/json/platform_access_subscription_item.json @@ -0,0 +1,65 @@ +{ + "id": "si_QdM8c5cisMLrb8", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1723258390, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1Pijb9HSA9cXarIclBhJDy3G", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 2000, + "amount_decimal": "2000", + "billing_scheme": "per_unit", + "created": 1722459495, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": { + "patch_install_limit": "50000" + }, + "meter": null, + "nickname": null, + "product": "prod_QZtNd5YModWM7n", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1Pijb9HSA9cXarIclBhJDy3G", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1722459495, + "currency": "usd", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": null, + "metadata": { + "patch_install_limit": "50000" + }, + "nickname": null, + "product": "prod_QZtNd5YModWM7n", + "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": 2000, + "unit_amount_decimal": "2000" + }, + "quantity": 1, + "subscription": "sub_1Pm5QYHSA9cXarIcRDsMStDo", + "tax_rates": [] +} \ No newline at end of file diff --git a/packages/stripe_api/test/fixtures/stripe/json/subscription.json b/packages/stripe_api/test/fixtures/stripe/json/subscription.json new file mode 100644 index 00000000..511968f4 --- /dev/null +++ b/packages/stripe_api/test/fixtures/stripe/json/subscription.json @@ -0,0 +1,146 @@ +{ + "id": "sub_1MvjPuHSA9cXarIcaWYNaezR", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "enabled": false + }, + "billing_cycle_anchor": 1681228054, + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": 1683820054, + "cancellation_details": { + "comment": null, + "feedback": null, + "reason": null + }, + "collection_method": "charge_automatically", + "created": 1681228054, + "currency": "usd", + "current_period_end": 1683820054, + "current_period_start": 1681228054, + "customer": "cus_Nh7fUR7HHhO8xT", + "days_until_due": null, + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discount": null, + "ended_at": null, + "items": { + "object": "list", + "data": [ + { + "id": "si_Nh7f7SwD99EW6w", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1681228055, + "metadata": {}, + "plan": { + "id": "price_1MvjPuHSA9cXarIcfmWAQo72", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 1500, + "amount_decimal": "1500", + "billing_scheme": "per_unit", + "created": 1681228054, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "nickname": null, + "product": "prod_Nh7fDKPeoghLht", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1MvjPuHSA9cXarIcfmWAQo72", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1681228054, + "currency": "usd", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": null, + "metadata": {}, + "nickname": null, + "product": "prod_Nh7fDKPeoghLht", + "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": 1500, + "unit_amount_decimal": "1500" + }, + "quantity": 1, + "subscription": "sub_1MvjPuHSA9cXarIcaWYNaezR", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1MvjPuHSA9cXarIcaWYNaezR" + }, + "latest_invoice": "in_1MvjPuHSA9cXarIcRh4yZJfr", + "livemode": false, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": null, + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "plan": { + "id": "price_1MvjPuHSA9cXarIcfmWAQo72", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 1500, + "amount_decimal": "1500", + "billing_scheme": "per_unit", + "created": 1681228054, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "nickname": null, + "product": "prod_Nh7fDKPeoghLht", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start_date": 1681228054, + "status": "active", + "test_clock": null, + "transfer_data": null, + "trial_end": null, + "trial_settings": { + "end_behavior": { + "missing_payment_method": "create_invoice" + } + }, + "trial_start": null +} diff --git a/packages/stripe_api/test/fixtures/stripe/json/subscription_created_event.json b/packages/stripe_api/test/fixtures/stripe/json/subscription_created_event.json new file mode 100644 index 00000000..41099656 --- /dev/null +++ b/packages/stripe_api/test/fixtures/stripe/json/subscription_created_event.json @@ -0,0 +1,161 @@ +{ + "id": "evt_1MvjPxHSA9cXarIcNoXsPPxL", + "object": "event", + "api_version": "2022-11-15", + "created": 1681228056, + "data": { + "object": { + "id": "sub_1MvjPuHSA9cXarIcaWYNaezR", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "enabled": false + }, + "billing_cycle_anchor": 1681228054, + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "cancellation_details": { + "comment": null, + "feedback": null, + "reason": null + }, + "collection_method": "charge_automatically", + "created": 1681228054, + "currency": "usd", + "current_period_end": 1683820054, + "current_period_start": 1681228054, + "customer": "cus_Nh7fUR7HHhO8xT", + "days_until_due": null, + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discount": null, + "ended_at": null, + "items": { + "object": "list", + "data": [ + { + "id": "si_Nh7f7SwD99EW6w", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1681228055, + "metadata": {}, + "plan": { + "id": "price_1MvjPuHSA9cXarIcfmWAQo72", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 1500, + "amount_decimal": "1500", + "billing_scheme": "per_unit", + "created": 1681228054, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "nickname": null, + "product": "prod_Nh7fDKPeoghLht", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1MvjPuHSA9cXarIcfmWAQo72", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1681228054, + "currency": "usd", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": null, + "metadata": {}, + "nickname": null, + "product": "prod_Nh7fDKPeoghLht", + "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": 1500, + "unit_amount_decimal": "1500" + }, + "quantity": 1, + "subscription": "sub_1MvjPuHSA9cXarIcaWYNaezR", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1MvjPuHSA9cXarIcaWYNaezR" + }, + "latest_invoice": "in_1MvjPuHSA9cXarIcRh4yZJfr", + "livemode": false, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": null, + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "plan": { + "id": "price_1MvjPuHSA9cXarIcfmWAQo72", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 1500, + "amount_decimal": "1500", + "billing_scheme": "per_unit", + "created": 1681228054, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "nickname": null, + "product": "prod_Nh7fDKPeoghLht", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start_date": 1681228054, + "status": "active", + "test_clock": null, + "transfer_data": null, + "trial_end": null, + "trial_settings": { + "end_behavior": { + "missing_payment_method": "create_invoice" + } + }, + "trial_start": null + } + }, + "livemode": false, + "pending_webhooks": 3, + "request": { + "id": "req_doI5SOWkABZxw7", + "idempotency_key": "593d6a9f-6489-4f54-8a20-87472f8287a6" + }, + "type": "customer.subscription.created" +} diff --git a/packages/stripe_api/test/fixtures/stripe/json/subscription_with_pricing_tiers.json b/packages/stripe_api/test/fixtures/stripe/json/subscription_with_pricing_tiers.json new file mode 100644 index 00000000..cdaaee09 --- /dev/null +++ b/packages/stripe_api/test/fixtures/stripe/json/subscription_with_pricing_tiers.json @@ -0,0 +1,197 @@ +{ + "id": "sub_1NzOsBHSA9cXarIchHmEjlmc", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "enabled": false + }, + "billing_cycle_anchor": 1696878731, + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "cancellation_details": { + "comment": null, + "feedback": null, + "reason": null + }, + "collection_method": "charge_automatically", + "created": 1696878731, + "currency": "usd", + "current_period_end": 1699557131, + "current_period_start": 1696878731, + "customer": "cus_Omyp1oQqpNqsWQ", + "days_until_due": null, + "default_payment_method": "pm_1NzOsAHSA9cXarIcblZrdytw", + "default_source": null, + "default_tax_rates": [], + "description": null, + "discount": null, + "ended_at": null, + "items": { + "object": "list", + "data": [ + { + "id": "si_OmypkJfzhERoOd", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1696878732, + "metadata": {}, + "plan": { + "id": "price_1NzOkSHSA9cXarIcvNNb31Ou", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": null, + "amount_decimal": null, + "billing_scheme": "tiered", + "created": 1696878252, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "nickname": "Teams (Volume)", + "product": "prod_Nj1EDNMwkkS0xc", + "tiers_mode": "volume", + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1NzOkSHSA9cXarIcvNNb31Ou", + "object": "price", + "active": true, + "billing_scheme": "tiered", + "created": 1696878252, + "currency": "usd", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": null, + "metadata": {}, + "nickname": "Teams (Volume)", + "product": "prod_Nj1EDNMwkkS0xc", + "recurring": { + "aggregate_usage": null, + "interval": "month", + "interval_count": 1, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "exclusive", + "tiers": [ + { + "flat_amount": 2000, + "flat_amount_decimal": "2000", + "unit_amount": null, + "unit_amount_decimal": null, + "up_to": 50000 + }, + { + "flat_amount": 10000, + "flat_amount_decimal": "10000", + "unit_amount": null, + "unit_amount_decimal": null, + "up_to": 300000 + }, + { + "flat_amount": 30000, + "flat_amount_decimal": "30000", + "unit_amount": null, + "unit_amount_decimal": null, + "up_to": 1000000 + }, + { + "flat_amount": 70000, + "flat_amount_decimal": "70000", + "unit_amount": null, + "unit_amount_decimal": null, + "up_to": 2500000 + }, + { + "flat_amount": 125000, + "flat_amount_decimal": "125000", + "unit_amount": null, + "unit_amount_decimal": null, + "up_to": 5000000 + }, + { + "flat_amount": 200000, + "flat_amount_decimal": "200000", + "unit_amount": null, + "unit_amount_decimal": null, + "up_to": 10000000 + }, + { + "flat_amount": 500000, + "flat_amount_decimal": "500000", + "unit_amount": null, + "unit_amount_decimal": null, + "up_to": null + } + ], + "tiers_mode": "volume", + "transform_quantity": null, + "type": "recurring", + "unit_amount": null, + "unit_amount_decimal": null + }, + "quantity": 2000000, + "subscription": "sub_1NzOsBHSA9cXarIchHmEjlmc", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1NzOsBHSA9cXarIchHmEjlmc" + }, + "latest_invoice": "in_1NzOsBHSA9cXarIcfpuFJuwh", + "livemode": false, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": null, + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "plan": { + "id": "price_1NzOkSHSA9cXarIcvNNb31Ou", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": null, + "amount_decimal": null, + "billing_scheme": "tiered", + "created": 1696878252, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "nickname": "Teams (Volume)", + "product": "prod_Nj1EDNMwkkS0xc", + "tiers_mode": "volume", + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 2000000, + "schedule": null, + "start_date": 1696878731, + "status": "active", + "test_clock": null, + "transfer_data": null, + "trial_end": null, + "trial_settings": { + "end_behavior": { + "missing_payment_method": "create_invoice" + } + }, + "trial_start": null +} diff --git a/packages/stripe_api/test/fixtures/stripe/json/trialing_subscription.json b/packages/stripe_api/test/fixtures/stripe/json/trialing_subscription.json new file mode 100644 index 00000000..c0874a2a --- /dev/null +++ b/packages/stripe_api/test/fixtures/stripe/json/trialing_subscription.json @@ -0,0 +1,161 @@ +{ + "id": "sub_1OL84vHSA9cXarIcNIxi0Xho", + "object": "subscription", + "application": null, + "application_fee_percent": null, + "automatic_tax": { + "enabled": true, + "liability": { + "type": "self" + } + }, + "billing_cycle_anchor": 1788053706, + "billing_cycle_anchor_config": null, + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "cancellation_details": { + "comment": null, + "feedback": null, + "reason": null + }, + "collection_method": "send_invoice", + "created": 1702057389, + "currency": "usd", + "current_period_end": 1788053706, + "current_period_start": 1725845719, + "customer": "cus_NnCoUcv8aBXCA2", + "days_until_due": 999, + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discount": null, + "discounts": [], + "ended_at": null, + "invoice_settings": { + "account_tax_ids": null, + "issuer": { + "type": "self" + } + }, + "items": { + "object": "list", + "data": [ + { + "id": "si_P9QwtgfcYKifTz", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1702057390, + "discounts": [], + "metadata": {}, + "plan": { + "id": "price_1NzOwCHSA9cXarIciu4Vmzm0", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": null, + "amount_decimal": null, + "billing_scheme": "tiered", + "created": 1696878980, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": true, + "metadata": {}, + "meter": null, + "nickname": "Team", + "product": "prod_NVGDFFgUebtGR6", + "tiers_mode": "volume", + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "price": { + "id": "price_1NzOwCHSA9cXarIciu4Vmzm0", + "object": "price", + "active": true, + "billing_scheme": "tiered", + "created": 1696878980, + "currency": "usd", + "custom_unit_amount": null, + "livemode": true, + "lookup_key": null, + "metadata": {}, + "nickname": "Team", + "product": "prod_NVGDFFgUebtGR6", + "recurring": { + "aggregate_usage": null, + "interval": "month", + "interval_count": 1, + "meter": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": "volume", + "transform_quantity": null, + "type": "recurring", + "unit_amount": null, + "unit_amount_decimal": null + }, + "quantity": 50000, + "subscription": "sub_1OL84vHSA9cXarIcNIxi0Xho", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_1OL84vHSA9cXarIcNIxi0Xho" + }, + "latest_invoice": "in_1PwwVfHSA9cXarIcv6zzWmnb", + "livemode": true, + "metadata": {}, + "next_pending_invoice_item_invoice": null, + "on_behalf_of": null, + "pause_collection": null, + "payment_settings": { + "payment_method_options": null, + "payment_method_types": null, + "save_default_payment_method": "off" + }, + "pending_invoice_item_interval": null, + "pending_setup_intent": null, + "pending_update": null, + "plan": { + "id": "price_1NzOwCHSA9cXarIciu4Vmzm0", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": null, + "amount_decimal": null, + "billing_scheme": "tiered", + "created": 1696878980, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": true, + "metadata": {}, + "meter": null, + "nickname": "Team", + "product": "prod_NVGDFFgUebtGR6", + "tiers_mode": "volume", + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 50000, + "schedule": null, + "start_date": 1702057389, + "status": "trialing", + "test_clock": null, + "transfer_data": null, + "trial_end": 1788053706, + "trial_settings": { + "end_behavior": { + "missing_payment_method": "create_invoice" + } + }, + "trial_start": 1725845719 +} \ No newline at end of file diff --git a/packages/stripe_api/test/fixtures/stripe/stripe_fixtures.dart b/packages/stripe_api/test/fixtures/stripe/stripe_fixtures.dart new file mode 100644 index 00000000..6b63881a --- /dev/null +++ b/packages/stripe_api/test/fixtures/stripe/stripe_fixtures.dart @@ -0,0 +1,95 @@ +import 'dart:convert'; +import 'dart:io'; + +const stripeJsonPath = 'test/fixtures/stripe/json'; + +String get checkoutSessionCompletedEventJsonString => File( + '$stripeJsonPath/checkout_session_completed_event.json', +).readAsStringSync(); + +Map get checkoutSessionCompletedEventJson => + jsonDecode(checkoutSessionCompletedEventJsonString) as Map; + +String get createPaymentLinkJsonString => + File('$stripeJsonPath/create_payment_link.json').readAsStringSync(); + +String get customerJsonString => + File('$stripeJsonPath/customer.json').readAsStringSync(); + +Map get customerJson => + jsonDecode(customerJsonString) as Map; + +String get customerWithEmptySubscriptionJsonString => File( + '$stripeJsonPath/customer_with_empty_subscriptions.json', +).readAsStringSync(); + +String get customerWithSubscriptionJsonString => + File('$stripeJsonPath/customer_with_subscription.json').readAsStringSync(); + +String get customerWithInactiveSubscriptionJsonString => File( + '$stripeJsonPath/customer_with_inactive_subscription.json', +).readAsStringSync(); + +String get subscriptionJsonString => + File('$stripeJsonPath/subscription.json').readAsStringSync(); + +Map get subscriptionJson => + jsonDecode(subscriptionJsonString) as Map; + +String get trialingSubscriptionString => + File('$stripeJsonPath/trialing_subscription.json').readAsStringSync(); + +Map get trialingSubscriptionJson => + jsonDecode(trialingSubscriptionString) as Map; + +String get subscriptionWithPricingTiersJsonString => File( + '$stripeJsonPath/subscription_with_pricing_tiers.json', +).readAsStringSync(); + +Map get subscriptionWithPricingTiersJson => + jsonDecode(subscriptionWithPricingTiersJsonString) as Map; + +String get subscriptionCreatedEventJsonString => + File('$stripeJsonPath/subscription_created_event.json').readAsStringSync(); + +Map get subscriptionCreatedEventJson => + jsonDecode(subscriptionCreatedEventJsonString) as Map; + +String get customerSearchOneResultWithExpandedSubscriptionsJsonString => File( + '$stripeJsonPath/customer_search_one_result_with_expanded_subscriptions.json', +).readAsStringSync(); + +Map get customerSearchOneResultWithExpandedSubscriptionsJson => + jsonDecode(customerSearchOneResultWithExpandedSubscriptionsJsonString) + as Map; + +String get meterEventSummariesWithNoMorePagesJsonString => File( + '$stripeJsonPath/meter_event_summaries_no_more_pages.json', +).readAsStringSync(); + +Map get meterEventSummariesWithNoMorePagesJson => + jsonDecode(meterEventSummariesWithNoMorePagesJsonString) + as Map; + +String get meterEventSummariesWithMorePagesJsonString => File( + '$stripeJsonPath/meter_event_summaries_with_more_pages.json', +).readAsStringSync(); + +Map get meterEventSummariesWithMorePagesJson => + jsonDecode(meterEventSummariesWithMorePagesJsonString) + as Map; + +String get platformAccessSubscriptionItemJsonString => File( + '$stripeJsonPath/platform_access_subscription_item.json', +).readAsStringSync(); + +Map get subscriptionItemJson => + jsonDecode(platformAccessSubscriptionItemJsonString) + as Map; + +String get payAsYouGoSubscriptionItemJsonString => File( + '$stripeJsonPath/pay_as_you_go_subscription_item.json', +).readAsStringSync(); + +Map get payAsYouGoSubscriptionItemJson => + jsonDecode(payAsYouGoSubscriptionItemJsonString) as Map; diff --git a/packages/stripe_api/test/src/converters/timestamp_converter_test.dart b/packages/stripe_api/test/src/converters/timestamp_converter_test.dart new file mode 100644 index 00000000..45b7ca12 --- /dev/null +++ b/packages/stripe_api/test/src/converters/timestamp_converter_test.dart @@ -0,0 +1,27 @@ +import 'package:stripe_api/src/converters/timestamp_converter.dart'; +import 'package:test/test.dart'; + +void main() { + group(TimestampConverter, () { + group('toJson()', () { + test('converts a DateTime to a timestamp', () { + const converter = TimestampConverter(); + final dateTime = DateTime(2023); + final timestamp = converter.toJson(dateTime); + expect(timestamp, equals(dateTime.millisecondsSinceEpoch ~/ 1000)); + }); + }); + + group('fromJson()', () { + test('converts a timestamp to a DateTime', () { + const converter = TimestampConverter(); + const timestamp = 1672552800; + final dateTime = converter.fromJson(timestamp); + expect( + dateTime, + equals(DateTime.fromMillisecondsSinceEpoch(timestamp * 1000)), + ); + }); + }); + }); +} diff --git a/packages/stripe_api/test/src/models/stripe_checkout_session_test.dart b/packages/stripe_api/test/src/models/stripe_checkout_session_test.dart new file mode 100644 index 00000000..8b940843 --- /dev/null +++ b/packages/stripe_api/test/src/models/stripe_checkout_session_test.dart @@ -0,0 +1,17 @@ +import 'package:stripe_api/stripe_api.dart'; +import 'package:test/test.dart'; + +import '../../fixtures/stripe/stripe_fixtures.dart'; + +void main() { + group(StripeCheckoutSession, () { + test('deserializes from json', () async { + final sessionEvent = StripeEvent.fromJson( + checkoutSessionCompletedEventJson, + ); + final session = sessionEvent.object; + expect(session.customerId, 'cus_123'); + expect(session.metadata, {'shorebird_email': 'tester@shorebird.dev'}); + }); + }); +} diff --git a/packages/stripe_api/test/src/models/stripe_customer_test.dart b/packages/stripe_api/test/src/models/stripe_customer_test.dart new file mode 100644 index 00000000..87cc548a --- /dev/null +++ b/packages/stripe_api/test/src/models/stripe_customer_test.dart @@ -0,0 +1,42 @@ +import 'package:stripe_api/stripe_api.dart'; +import 'package:test/test.dart'; + +import '../../fixtures/stripe/stripe_fixtures.dart'; + +void main() { + group(StripeCustomer, () { + test('deserializes from json', () { + final customer = StripeCustomer.fromJson(customerJson); + + expect(customer.id, 'cus_123'); + expect(customer.name, 'Jane Doe'); + expect(customer.email, 'test@shorebird.dev'); + expect(customer.subscriptions, null); + + expect( + customer.toString(), + equals('Jane Doe - test@shorebird.dev (id:cus_123)'), + ); + }); + + test('deserializes from list with expanded subscriptions', () { + final data = + customerSearchOneResultWithExpandedSubscriptionsJson['data'] as List; + final customerJson = data.first as Map; + final customer = StripeCustomer.fromJson(customerJson); + + expect(customer.id, 'cus_123'); + expect(customer.name, 'Jane Doe'); + expect(customer.email, 'test@shorebird.dev'); + + final subscriptions = customer.subscriptions; + expect(subscriptions!.length, equals(1)); + + final subscription = subscriptions.first; + + expect(subscription.id, equals('sub_1Mo9r2HSA9cXarIcVgf2GQt4')); + expect(subscription.customer, equals('cus_123')); + expect(subscription.status.name, equals('active')); + }); + }); +} diff --git a/packages/stripe_api/test/src/models/stripe_event_test.dart b/packages/stripe_api/test/src/models/stripe_event_test.dart new file mode 100644 index 00000000..68b4e6c2 --- /dev/null +++ b/packages/stripe_api/test/src/models/stripe_event_test.dart @@ -0,0 +1,43 @@ +import 'package:stripe_api/stripe_api.dart'; +import 'package:test/test.dart'; + +import '../../fixtures/stripe/stripe_fixtures.dart'; + +void main() { + group(StripeEvent, () { + test('deserializes checkout session event', () { + final event = StripeEvent.fromJson( + checkoutSessionCompletedEventJson, + ); + expect(event.object, isA()); + expect( + event.created, + DateTime.fromMillisecondsSinceEpoch(1681743321 * 1000), + ); + }); + + test('deserializes subscription event', () { + final event = StripeEvent.fromJson( + subscriptionCreatedEventJson, + ); + expect(event.object, isA()); + expect( + event.created, + DateTime.fromMillisecondsSinceEpoch(1681228056 * 1000), + ); + }); + + test('throws exception on unknown event type', () { + expect( + () => StripeEvent.fromJson({ + 'id': 'evt_123', + 'created': 1681228056, + 'data': { + 'object': {'object': 'bogus.object'}, + }, + }), + throwsA(isA()), + ); + }); + }); +} diff --git a/packages/stripe_api/test/src/models/stripe_price_test.dart b/packages/stripe_api/test/src/models/stripe_price_test.dart new file mode 100644 index 00000000..d60a5196 --- /dev/null +++ b/packages/stripe_api/test/src/models/stripe_price_test.dart @@ -0,0 +1,175 @@ +import 'package:decimal/decimal.dart'; +import 'package:stripe_api/stripe_api.dart'; +import 'package:test/test.dart'; + +import '../../fixtures/stripe/stripe_fixtures.dart'; + +void main() { + group(StripePrice, () { + group('(de)serialization', () { + test('can be deserialized from json', () { + final subscription = StripeSubscription.fromJson(subscriptionJson); + final price = subscription.items.first.price; + + // cspell:disable-next-line + expect(price.id, 'price_1MvjPuHSA9cXarIcfmWAQo72'); + // cspell:disable-next-line + expect(price.productId, 'prod_Nh7fDKPeoghLht'); + expect(price.currency, 'usd'); + expect(price.billingScheme, BillingScheme.perUnit); + expect(price.unitAmount, 1500); + expect(price.unitAmountDecimal, Decimal.fromInt(1500)); + expect(price.tiers, isNull); + expect(price.usageType, UsageType.licensed); + expect(price.meterId, isNull); + }); + + test('can be deserialized from json (pay as you go)', () { + final subscriptionItem = StripeSubscriptionItem.fromJson( + payAsYouGoSubscriptionItemJson, + ); + + final price = subscriptionItem.price; + // cspell:disable-next-line + expect(price.id, 'price_1Pm4YJHSA9cXarIcpDllPtvw'); + // cspell:disable-next-line + expect(price.productId, 'prod_QdLE0bb1qFBFMv'); + expect(price.currency, 'usd'); + expect(price.billingScheme, BillingScheme.perUnit); + expect(price.unitAmount, isNull); + expect(price.unitAmountDecimal, Decimal.parse('0.04')); + expect(price.tiers, isNull); + expect(price.usageType, UsageType.metered); + expect(price.meterId, 'mtr_test_61QvSUDTnLya5cdwG41HSA9cXarIc144'); + }); + }); + + group('StripePriceTiers', () { + const fiftyThousandTier = StripePriceTier( + flatAmount: 2000, + flatAmountDecimal: '2000', + unitAmount: null, + unitAmountDecimal: null, + upTo: 50000, + ); + const threeHundredThousandTier = StripePriceTier( + flatAmount: 10000, + flatAmountDecimal: '1000', + unitAmount: null, + unitAmountDecimal: null, + upTo: 300000, + ); + const oneMillionTier = StripePriceTier( + flatAmount: 30000, + flatAmountDecimal: '30000', + unitAmount: null, + unitAmountDecimal: null, + upTo: 1000000, + ); + const twoAndAHalfMillionTier = StripePriceTier( + flatAmount: 70000, + flatAmountDecimal: '70000', + unitAmount: null, + unitAmountDecimal: null, + upTo: 2500000, + ); + const fiveMillionTier = StripePriceTier( + flatAmount: 125000, + flatAmountDecimal: '125000', + unitAmount: null, + unitAmountDecimal: null, + upTo: 5000000, + ); + const tenMillionTier = StripePriceTier( + flatAmount: 200000, + flatAmountDecimal: '200000', + unitAmount: null, + unitAmountDecimal: null, + upTo: 10000000, + ); + const maxTier = StripePriceTier( + flatAmount: 500000, + flatAmountDecimal: '500000', + unitAmount: null, + unitAmountDecimal: null, + upTo: null, + ); + + late List tiers; + + setUp(() { + tiers = [ + fiftyThousandTier, + threeHundredThousandTier, + oneMillionTier, + twoAndAHalfMillionTier, + fiveMillionTier, + tenMillionTier, + maxTier, + ]; + }); + + test('tierForQuantity returns the correct tier', () { + final price = StripePrice( + id: 'test-price-id', + productId: 'test-product-id', + currency: 'usd', + billingScheme: BillingScheme.tiered, + tiers: tiers, + ); + + // Values equal to a tier's [upTo] field should return that tier. + expect(price.tierForQuantity(50000), fiftyThousandTier); + expect(price.tierForQuantity(300000), threeHundredThousandTier); + expect(price.tierForQuantity(1000000), oneMillionTier); + expect(price.tierForQuantity(2500000), twoAndAHalfMillionTier); + expect(price.tierForQuantity(5000000), fiveMillionTier); + expect(price.tierForQuantity(10000000), tenMillionTier); + + // Values between two tiers' [upTo] fields should return the higher + // tier. + expect(price.tierForQuantity(40000), fiftyThousandTier); + expect(price.tierForQuantity(2000000), twoAndAHalfMillionTier); + expect(price.tierForQuantity(2500001), fiveMillionTier); + + // Values above any tier's [upTo] field should return the last bounded + // tier. + expect(price.tierForQuantity(99999999), tenMillionTier); + }); + + group('when tiers are shuffled', () { + setUp(() { + tiers.shuffle(); + }); + + test('tierForQuantity returns the correct tier', () { + final price = StripePrice( + id: 'test-price-id', + productId: 'test-product-id', + currency: 'usd', + billingScheme: BillingScheme.tiered, + tiers: tiers, + ); + + // Values equal to a tier's [upTo] field should return that tier. + expect(price.tierForQuantity(50000), fiftyThousandTier); + expect(price.tierForQuantity(300000), threeHundredThousandTier); + expect(price.tierForQuantity(1000000), oneMillionTier); + expect(price.tierForQuantity(2500000), twoAndAHalfMillionTier); + expect(price.tierForQuantity(5000000), fiveMillionTier); + expect(price.tierForQuantity(10000000), tenMillionTier); + + // Values between two tiers' [upTo] fields should return the higher + // tier. + expect(price.tierForQuantity(40000), fiftyThousandTier); + expect(price.tierForQuantity(2000000), twoAndAHalfMillionTier); + expect(price.tierForQuantity(2500001), fiveMillionTier); + + // Values above any tier's [upTo] field should return the last bounded + // tier. + expect(price.tierForQuantity(99999999), tenMillionTier); + }); + }); + }); + }); +} diff --git a/packages/stripe_api/test/src/models/stripe_subscription_test.dart b/packages/stripe_api/test/src/models/stripe_subscription_test.dart new file mode 100644 index 00000000..f3cb0876 --- /dev/null +++ b/packages/stripe_api/test/src/models/stripe_subscription_test.dart @@ -0,0 +1,210 @@ +// cspell:words sub_1MvjPuHSA9cXarIcaWYNaezR cus_Nh7fUR7HHhO8xT +import 'package:stripe_api/stripe_api.dart'; +import 'package:test/test.dart'; + +import '../../fixtures/stripe/stripe_fixtures.dart'; + +void main() { + group(StripeSubscription, () { + test('deserializes from json', () { + final subscription = StripeSubscription.fromJson(subscriptionJson); + + expect(subscription.id, 'sub_1MvjPuHSA9cXarIcaWYNaezR'); + expect(subscription.cancelAtPeriodEnd, false); + expect( + subscription.canceledAt, + DateTime.fromMillisecondsSinceEpoch(1683820054 * 1000), + ); + expect( + subscription.currentPeriodEnd, + DateTime.fromMillisecondsSinceEpoch(1683820054 * 1000), + ); + expect( + subscription.currentPeriodStart, + DateTime.fromMillisecondsSinceEpoch(1681228054 * 1000), + ); + expect(subscription.customer, 'cus_Nh7fUR7HHhO8xT'); + expect(subscription.endedAt, null); + expect( + subscription.startDate, + DateTime.fromMillisecondsSinceEpoch(1681228054 * 1000), + ); + expect(subscription.status, StripeSubscriptionStatus.active); + expect(subscription.trialStart, isNull); + expect(subscription.trialEnd, isNull); + }); + + test('deserializes from json with missing items data', () { + final updatedSubscriptionJson = subscriptionJson; + (updatedSubscriptionJson['items'] as Map).remove('data'); + final subscription = StripeSubscription.fromJson(updatedSubscriptionJson); + + expect(subscription.items, isEmpty); + }); + + group('trial subscription', () { + test('deserializes from json', () { + final subscription = StripeSubscription.fromJson( + trialingSubscriptionJson, + ); + + // cspell:disable-next-line + expect(subscription.id, 'sub_1OL84vHSA9cXarIcNIxi0Xho'); + expect(subscription.cancelAtPeriodEnd, false); + expect(subscription.currentPeriodEnd, isNotNull); + expect(subscription.currentPeriodStart, isNotNull); + // cspell:disable-next-line + expect(subscription.customer, 'cus_NnCoUcv8aBXCA2'); + expect(subscription.endedAt, null); + expect(subscription.startDate, isNotNull); + expect(subscription.status, StripeSubscriptionStatus.trialing); + expect( + subscription.trialStart, + DateTime.fromMillisecondsSinceEpoch(1725845719000), + ); + expect( + subscription.trialEnd, + DateTime.fromMillisecondsSinceEpoch(1788053706000), + ); + expect(subscription.isActiveOrTrial, isTrue); + }); + }); + + group('isActiveOrTrial', () { + test('returns true if status is active', () { + final subscription = StripeSubscription( + id: 'sub_123', + cancelAtPeriodEnd: false, + currentPeriodEnd: DateTime.now(), + currentPeriodStart: DateTime.now(), + customer: 'cus_123', + startDate: DateTime.now(), + status: StripeSubscriptionStatus.active, + items: [], + ); + expect(subscription.isActiveOrTrial, isTrue); + }); + + test('returns true if subscription is trialing', () { + final subscription = StripeSubscription.fromJson( + trialingSubscriptionJson, + ); + expect(subscription.isActiveOrTrial, isTrue); + }); + + test('returns false if status is not active', () { + final subscription = StripeSubscription( + id: 'sub_123', + cancelAtPeriodEnd: false, + currentPeriodEnd: DateTime.now(), + currentPeriodStart: DateTime.now(), + customer: 'cus_123', + startDate: DateTime.now(), + status: StripeSubscriptionStatus.canceled, + items: [], + ); + expect(subscription.isActiveOrTrial, isFalse); + }); + }); + + group('totalCost', () { + test('returns 0 if subscription contains no items', () { + final subscription = StripeSubscription( + id: 'sub_123', + cancelAtPeriodEnd: false, + currentPeriodEnd: DateTime.now(), + currentPeriodStart: DateTime.now(), + customer: 'cus_123', + startDate: DateTime.now(), + status: StripeSubscriptionStatus.active, + items: [], + ); + expect(subscription.totalCost, 0); + }); + + test('returns sum of item costs', () { + final subscription = StripeSubscription( + id: 'sub_123', + cancelAtPeriodEnd: false, + currentPeriodEnd: DateTime.now(), + currentPeriodStart: DateTime.now(), + customer: 'cus_123', + startDate: DateTime.now(), + status: StripeSubscriptionStatus.active, + items: [ + const StripeSubscriptionItem( + id: 'item_1', + price: StripePrice( + id: 'price_1', + currency: 'usd', + productId: 'prod_123', + unitAmount: 1000, + billingScheme: BillingScheme.perUnit, + ), + quantity: 1000, + ), + const StripeSubscriptionItem( + id: 'item_2', + price: StripePrice( + id: 'price_2', + currency: 'usd', + productId: 'prod_123', + unitAmount: 2000, + billingScheme: BillingScheme.perUnit, + ), + quantity: 1000, + ), + const StripeSubscriptionItem( + id: 'item_3', + price: StripePrice( + id: 'price_3', + currency: 'usd', + productId: 'prod_123', + unitAmount: 3000, + billingScheme: BillingScheme.perUnit, + ), + quantity: 1000, + ), + ], + ); + expect(subscription.totalCost, 6000); + }); + }); + + group('hasMeteredBilling', () { + late StripeSubscription subscription; + + group("when a subscription item's price has a metered usage type", () { + setUp(() { + final subscriptionItem = StripeSubscriptionItem.fromJson( + payAsYouGoSubscriptionItemJson, + ); + subscription = StripeSubscription( + id: 'sub_123', + cancelAtPeriodEnd: false, + currentPeriodEnd: DateTime.now(), + currentPeriodStart: DateTime.now(), + customer: 'cus_123', + startDate: DateTime.now(), + status: StripeSubscriptionStatus.active, + items: [subscriptionItem], + ); + }); + + test('returns true', () { + expect(subscription.hasMeteredBilling, isTrue); + }); + }); + + group("when no subscription item's price has a metered usage type", () { + setUp(() { + subscription = StripeSubscription.fromJson(subscriptionJson); + }); + + test('returns false', () { + expect(subscription.hasMeteredBilling, isFalse); + }); + }); + }); + }); +} diff --git a/packages/stripe_api/test/src/stripe_api_test.dart b/packages/stripe_api/test/src/stripe_api_test.dart new file mode 100644 index 00000000..ef016b0b --- /dev/null +++ b/packages/stripe_api/test/src/stripe_api_test.dart @@ -0,0 +1,364 @@ +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:mocktail/mocktail.dart'; +import 'package:stripe_api/stripe_api.dart'; +import 'package:test/test.dart'; + +import '../fixtures/stripe/stripe_fixtures.dart'; + +class _MockHttpClient extends Mock implements http.Client {} + +void main() { + group(StripeApi, () { + const expectedAuthHeaders = { + HttpHeaders.authorizationHeader: 'Bearer secret', + }; + + late http.Client httpClient; + late StripeApi stripeApi; + + setUpAll(() { + registerFallbackValue(Uri.parse('https://www.google.com/')); + }); + + setUp(() { + httpClient = _MockHttpClient(); + stripeApi = StripeApi(client: httpClient, secretKey: 'secret'); + }); + + test('can be instantiated without an explicit httpClient', () { + expect(() => StripeApi(secretKey: 'secret'), returnsNormally); + }); + + group('fetchActiveSubscriptions', () { + final customerUri = Uri.parse( + 'https://api.stripe.com/v1/customers/cus_123?expand%5B%5D=subscriptions', + ); + + test('returns an empty list if the customer object is missing a ' + 'subscriptions list', () async { + when( + () => httpClient.get(customerUri, headers: any(named: 'headers')), + ).thenAnswer( + (_) async => http.Response(customerJsonString, HttpStatus.ok), + ); + + final subscriptions = await stripeApi.fetchActiveOrTrialSubscriptions( + customerId: 'cus_123', + ); + + expect(subscriptions, isEmpty); + }); + + test( + 'returns an empty list if the customer has no subscriptions', + () async { + when( + () => httpClient.get(customerUri, headers: any(named: 'headers')), + ).thenAnswer( + (_) async => http.Response( + customerWithEmptySubscriptionJsonString, + HttpStatus.ok, + ), + ); + + final subscriptions = await stripeApi.fetchActiveOrTrialSubscriptions( + customerId: 'cus_123', + ); + + expect(subscriptions, isEmpty); + }, + ); + + test( + "returns an empty list if customer's subscriptions are inactive", + () async { + when( + () => httpClient.get(customerUri, headers: any(named: 'headers')), + ).thenAnswer( + (_) async => http.Response( + customerWithInactiveSubscriptionJsonString, + HttpStatus.ok, + ), + ); + + final subscriptions = await stripeApi.fetchActiveOrTrialSubscriptions( + customerId: 'cus_123', + ); + + expect(subscriptions, isEmpty); + }, + ); + + test('returns subscriptions if the customer has subscriptions', () async { + when( + () => httpClient.get(customerUri, headers: any(named: 'headers')), + ).thenAnswer( + (_) async => + http.Response(customerWithSubscriptionJsonString, HttpStatus.ok), + ); + + final subscriptions = await stripeApi.fetchActiveOrTrialSubscriptions( + customerId: 'cus_123', + ); + + expect(subscriptions, isNotEmpty); + }); + }); + + group('fetchCustomer', () { + final uri = Uri.parse( + 'https://api.stripe.com/v1/customers/cus_123?expand%5B%5D=subscriptions', + ); + + test('throws exception if Stripe returns a non-200 response', () { + when( + () => httpClient.get(uri, headers: any(named: 'headers')), + ).thenAnswer( + (_) async => http.Response('Not found', HttpStatus.notFound), + ); + + expect( + () => stripeApi.fetchCustomer(customerId: 'cus_123'), + throwsException, + ); + + verify( + () => httpClient.get(uri, headers: expectedAuthHeaders), + ).called(1); + }); + + test('returns a customer on successful request', () async { + when( + () => httpClient.get(uri, headers: any(named: 'headers')), + ).thenAnswer( + (_) async => http.Response(customerJsonString, HttpStatus.ok), + ); + + final customer = await stripeApi.fetchCustomer(customerId: 'cus_123'); + + expect(customer, isNotNull); + expect(customer.id, 'cus_123'); + + verify( + () => httpClient.get(uri, headers: expectedAuthHeaders), + ).called(1); + }); + }); + + group('fetchSubscription', () { + const subscriptionId = 'sub_123'; + final uri = Uri.parse( + 'https://api.stripe.com/v1/subscriptions/sub_123', + ).replace(queryParameters: {'expand[]': 'items.data.price.tiers'}); + + test('throws exception if Stripe returns a non-200 response', () async { + when( + () => httpClient.get(uri, headers: any(named: 'headers')), + ).thenAnswer( + (_) async => http.Response('Not found', HttpStatus.notFound), + ); + + expect( + () => stripeApi.fetchSubscription(subscriptionId: subscriptionId), + throwsException, + ); + + verify( + () => httpClient.get(uri, headers: expectedAuthHeaders), + ).called(1); + }); + + test('returns a subscription on successful request', () async { + when( + () => httpClient.get(uri, headers: any(named: 'headers')), + ).thenAnswer( + (_) async => http.Response(subscriptionJsonString, HttpStatus.ok), + ); + + final subscription = await stripeApi.fetchSubscription( + subscriptionId: subscriptionId, + ); + + expect(subscription, isNotNull); + // cspell:disable-next-line + expect(subscription.id, 'sub_1MvjPuHSA9cXarIcaWYNaezR'); + expect(subscription.items.first.price.tiers, isNull); + }); + + test( + 'returns a subscription with pricing tiers on successful request', + () async { + when( + () => httpClient.get(uri, headers: any(named: 'headers')), + ).thenAnswer( + (_) async => http.Response( + subscriptionWithPricingTiersJsonString, + HttpStatus.ok, + ), + ); + + final subscription = await stripeApi.fetchSubscription( + subscriptionId: subscriptionId, + ); + + expect(subscription, isNotNull); + // cspell:disable-next-line + expect(subscription.id, 'sub_1NzOsBHSA9cXarIchHmEjlmc'); + final tiers = subscription.items.first.price.tiers; + expect(tiers, hasLength(7)); + expect(tiers?.first.flatAmount, 2000); + expect(tiers?.first.upTo, 50000); + expect(tiers?.last.upTo, isNull); + }, + ); + }); + + group('createMeterEvent', () { + final uri = Uri.parse('https://api.stripe.com/v1/billing/meter_events'); + + setUp(() { + when( + () => httpClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + ), + ).thenAnswer((_) async => http.Response('', HttpStatus.ok)); + }); + + test('sends the correct request', () async { + await stripeApi.createMeterEvent( + customerId: 'cus_123', + eventName: 'test_event', + value: 100, + timestamp: 1234, + ); + + verify( + () => httpClient.post( + uri, + headers: expectedAuthHeaders, + body: { + 'event_name': 'test_event', + 'timestamp': '1234', + 'payload[value]': '100', + 'payload[stripe_customer_id]': 'cus_123', + }, + ), + ).called(1); + }); + + group('when response has non-success status code', () { + setUp(() { + when( + () => httpClient.post( + any(), + headers: any(named: 'headers'), + body: any(named: 'body'), + ), + ).thenAnswer( + (_) async => http.Response('Not found', HttpStatus.notFound), + ); + }); + + test('throws exception', () async { + await expectLater( + () => stripeApi.createMeterEvent( + customerId: 'cus_123', + eventName: 'my-event', + value: 100, + ), + throwsException, + ); + }); + }); + }); + + group('getMeterEventSummaries', () { + const customerId = 'cus_123'; + const meterId = 'mtr_test_61Qs4Xo4ZBeo0c8Em41HSA9cXarIc0Ho'; + const startTimestamp = 0; + const endTimestamp = 60; + + setUp(() { + when( + () => httpClient.get( + Uri.parse( + 'https://api.stripe.com/v1/billing/meters/mtr_test_61Qs4Xo4ZBeo0c8Em41HSA9cXarIc0Ho/event_summaries?customer=cus_123&start_time=0&end_time=60&limit=100', + ), + headers: any(named: 'headers'), + ), + ).thenAnswer( + (_) async => http.Response( + meterEventSummariesWithMorePagesJsonString, + HttpStatus.ok, + ), + ); + + when( + () => httpClient.get( + Uri.parse( + 'https://api.stripe.com/v1/billing/meters/mtr_test_61Qs4Xo4ZBeo0c8Em41HSA9cXarIc0Ho/event_summaries?customer=cus_123&start_time=0&end_time=60&limit=100&starting_after=mtrusg_test_6041HSA9cXarIc6U76ce6L359f4ft60m5Xl3ox5sv7bt6bJ5bx5Y459D4Xt2E17ko6M86kt7kV3bl5QJ3U87PA60g6kp3Dn3kL7Gu3HU5Xl3ox5sv7bt6bY4p52Dr7od3Hc71E6go4od4LJ6cC6UM4t17Lc3Ta6ky3fx2D33fu3LI3oD3bk3Cy3Cy', + ), + headers: any(named: 'headers'), + ), + ).thenAnswer( + (_) async => http.Response( + meterEventSummariesWithNoMorePagesJsonString, + HttpStatus.ok, + ), + ); + }); + + test('returns all pages of meter summaries', () async { + final meterEventSummary1 = StripeMeterEventSummary.fromJson( + // Ignoring dynamic call for testing purposes. + // ignore: avoid_dynamic_calls + meterEventSummariesWithMorePagesJson['data'][0] + as Map, + ); + final meterEventSummary2 = StripeMeterEventSummary.fromJson( + // Ignoring dynamic call for testing purposes. + // ignore: avoid_dynamic_calls + meterEventSummariesWithNoMorePagesJson['data'][0] + as Map, + ); + + final result = await stripeApi.getMeterEventSummaries( + meterId: meterId, + customerId: customerId, + startTimestamp: startTimestamp, + endTimestamp: endTimestamp, + ); + + expect(result, hasLength(2)); + expect(result[0].toJson(), equals(meterEventSummary1.toJson())); + expect(result[1].toJson(), equals(meterEventSummary2.toJson())); + }); + + group('when response has non-success status code', () { + setUp(() { + when( + () => httpClient.get(any(), headers: any(named: 'headers')), + ).thenAnswer( + (_) async => http.Response('Not found', HttpStatus.notFound), + ); + }); + + test('throws exception', () async { + await expectLater( + () => stripeApi.getMeterEventSummaries( + meterId: meterId, + customerId: customerId, + startTimestamp: startTimestamp, + endTimestamp: endTimestamp, + ), + throwsException, + ); + }); + }); + }); + }); +} diff --git a/pubspec.yaml b/pubspec.yaml index 37cd8b50..abbcfb93 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,6 +11,7 @@ workspace: - packages/shorebird_cli - packages/shorebird_code_push_client - packages/shorebird_code_push_protocol + - packages/stripe_api dev_dependencies: very_good_analysis: ^9.0.0