Skip to content

Commit

Permalink
Allowed the SimpleBillingProvider to support cancellation
Browse files Browse the repository at this point in the history
  • Loading branch information
jezzsantos committed Jun 22, 2024
1 parent ed6e9e2 commit 054c1c3
Show file tree
Hide file tree
Showing 16 changed files with 331 additions and 188 deletions.
12 changes: 6 additions & 6 deletions docs/design-principles/0180-billing-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Here, we refer to the integration between the SaaS product and a preferred Billing Management System (BMS).

Examples of typical online BMS are: [Chargebee](https://www.chargebee.com/), [Maxio (formerly Chargify)](https://www.maxio.com/subscription-management), [Recurly](https://recurly.com/), [Zoho](https://www.zoho.com/us/billing/), and [Stripe Billing](https://stripe.com/billing).
Examples of typical online BMSs are: [Chargebee](https://www.chargebee.com/), [Maxio (formerly Chargify)](https://www.maxio.com/subscription-management), [Recurly](https://recurly.com/), [Zoho](https://www.zoho.com/us/billing/), and [Stripe Billing](https://stripe.com/billing).

We have some significant goals here with a billing integration:

Expand Down Expand Up @@ -227,15 +227,15 @@ The billing provider (`IBillingProvider`) is an abstraction that provides two se
1. Provides a HTTP service client to a (`IBillingGatewayService`) to directly access to the BMS to perform transactional commands.
2. Provides an `IBillingStateInterpreter` to manage the internal [cached] state of the subscription, plans and limits, quotas, etc from the BMS, in the product over time, as things change.

By default, the built-in `SimpleBillingProvider` is configured and injected at runtime.
By default, the built-in `SimpleBillingProvider` is configured, and injected at runtime.

> It can be easily unplugged and another `IBillingProvider` can be used to replace it, such as the: `ChargebeeBillingProvider`. See [Migrating To Another Billing Provider](../how-to-guides/900-migrate-billing-provider.md) for how to do that.
The `SimpleBillingProvider`essentially hardcodes its own behavior, since there is no 3rd party BMS service to integrate with.

As you can see, it keeps track of very minimal state (variables) that define only a unique `subscriptionId` and the `buyerId` of the subscription.

It also defines one hardcoded plan, that has zero cost, no trial period and no limits quotas, on a single tier (`Standard`) that always has a valid `PaymentMethod`. This plan, can be canceled or unsubscribed, but it basically reverts back to the original plan, so essentially it cannot be canceled, upgraded or downgraded.
It also defines one hardcoded plan, that has zero cost, no trial period and no limits quotas, on a single tier (`Standard`) that always has a valid `PaymentMethod`. This plan can be canceled or unsubscribed, but changing the plan (i.e. Upgrade/Downgrade) has no effect since it reverts back to the original plan.

It supports all the features any `IBillingProvider` could have, but it works in a limited way like this:

Expand All @@ -249,13 +249,13 @@ The `SimpleBillingProvider` works like this:
- Every `Organization` that gets created gets created with a new billing `Subscription` with the plan: `_simple_standard`.
- The `Organization` assigns the creator the org, the following default roles: `BillingAdmin` (also `Owner` and `Member`).
- The new `Subscription` is automatically created (via a notification) with a `BuyerId` to be the `CreatedById` of the new `Organization`.
- Any Upgrade, downgrade, or cancellation results in the same plan (`_simple_default`) and same Tier (`Standard`).
- The plan cannot be unsubscribed, either. It will revert to the same plan.
- Any Upgrade (or downgrade) results in the same plan (`_simple_default`) and the same Tier (`Standard`).
- Cancelling the plan (or Unsubscribing it) makes it revert to the `Unsubscribed` tier, with limited access to features.
- If the `Organization` is deleted, which is permitted, the `Subscription` is also deleted.

Bottom line: with this `SimpleBillingProvider`, no `Organization` can exist without a `Subscription` with the same plan (`_simple_standard`).

The benefit of using this `SimpleBillingProvider` by default, is that you can get your product running immediately, capture and collect the subscriptions of our customers, advertise a single free plan on your website (i.e. "freemium"), and users can view zero total invoice every month.
The benefit of using this `SimpleBillingProvider` as a "default" is that you can get your product running immediately, capture and collect the subscriptions of our customers, advertise a single free plan on your website (i.e., "freemium"), and users can view zero-total invoices every month.

One day in the future, when you are ready and have purchased a BMS, you can easily migrate those existing customers and subscriptions over to your new 3rd party system seamlessly via the built-in APIs of the `Subscriptions` subdomain.

Expand Down
62 changes: 31 additions & 31 deletions docs/how-to-guides/900-migrate-billing-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ Initially, SaaStack comes configured with a built-in `IBillingProvider` called `

This provider is a stand-in provider to be used in the early days of product development until the point you decide to replace it with an integration to a third-party Billing Management System (BMS), such as [Chargebee](https://www.chargebee.com/), [Maxio (formerly Chargify)](https://www.maxio.com/subscription-management), [Recurly](https://recurly.com/), [Zoho](https://www.zoho.com/us/billing/), and [Stripe Billing](https://stripe.com/billing).

At that point, you may have already onboarded many new customers, and each of them is going to have already created a billing `Subscription`. Now, the job will be to migrate the data that has been captured by the `SimpleBillingProvider` and use it to pre-populate subscriptions in your chosen third-party BMS.
At that point, you may have already onboarded many new customers, and each of them is going to have already created a billing `Subscription`.

Now, the job will be to migrate the data that has been captured by the current `IBillingProvider` and use it to pre-populate subscriptions in your chosen third-party BMS.

> The last thing you would want is to manually input the data you have already collected from existing customers into your new BMS.
Expand Down Expand Up @@ -44,35 +46,33 @@ This is the data you will need to import into your chosen BMS, during the migrat
```json
{
"subscriptions": [{
"id": "subscription_QR5hju7FDMIklw39GVCs",
"buyerId": "user_wYU128873MRRBRtjj3E",
"owningEntityId": "org_M36utr98Fdde8890BDEcS2",
"providerName": "simple_billing_provider",
"providerState": {
"SubscriptionId": "simplesub_dd45baa2188c43d39745344356781123"
},
"buyer": {
"id": "user_wYU128873MRRBRtjj3E",
"companyReference": "org_MM36utr98Fdde8890BDEcS2",
"firstName": "firstname",
"name": "{\"FirstName\":\"afirstname\",\"LastName\":\"alastname\"}",
"emailAddress": "[email protected]",
"address": "{\"City\":\"\",\"CountryCode\":\"NZL\",\"Line1\":\"\",\"Line2\":\"\",\"Line3\":\"\",\"State\":\"\",\"Zip\":\"\"}"
},
}
],
"metadata": {
"total": 3,
"limit": 100,
"offset": -1,
"sort": {
"direction": "Ascending"
},
"filter": {
"fields": []
}
"subscriptions": [
{
"buyer": {
"Address": "{\"City\":\"\",\"CountryCode\":\"NZL\",\"Line1\":\"\",\"Line2\":\"\",\"Line3\":\"\",\"State\":\"\",\"Zip\":\"\"}",
"CompanyReference": "org_SmpntwRFK0OOtQcoMu9N7g",
"EmailAddress": "[email protected]",
"Id": "user_KSASWz7eUq6zcVeUbGSzw",
"Name": "{\"FirstName\":\"afirstname\",\"LastName\":\"alastname\"}"
},
"buyerId": "user_KSASWz7eUq6zcVeUbGSzw",
"owningEntityId": "org_SmpntwRFK0OOtQcoMu9N7g",
"providerName": "simple_billing_provider",
"providerState": {
"BuyerId": "user_KSASWz7eUq6zcVeUbGSzw",
"SubscriptionId": "simplesub_4a84ac3c69c344568cca2867c29d6cc0"
},
"id": "subscription_MkiRvPBa0i4e7C2yjn3AA"
}
],
"metadata": {
"filter": {
"fields": []
},
"limit": 100,
"offset": -1,
"total": 1
}
}
```

Expand Down Expand Up @@ -104,11 +104,11 @@ Next, you would need to test and refine these scripts thoroughly so that they ar

In your chosen BMS, you will need to design and define the new pricing plans you intend to support for all your customers moving forward in this BMS.

> If you are using the `SimpleBillingProvider` prior to this step, you won't see much plan information in the exported data from the previous step, that's because this provider does not maintain much plan information at all. That's because it hardcodes a single plan for everyone's use. You can find that single hardcoded plan information in the `InProcessInMemSimpleBillingGatewayService`.
> If you are using the `SimpleBillingProvider` prior to this step, you won't see much plan information in the exported data from the previous step, that's because this provider does not maintain much plan information at all. It hardcodes a single plan for everyone's use. You can find that single hardcoded plan information in the `InProcessInMemSimpleBillingGatewayService`.
>
> You also need to remember that the `SimpleBillingProvider` has everyone on a "free" plan that requires no payment method and does not support a Trial period.
This means that when you import these subscriptions (created by the `SimpleBillingProvider`) into your new BMS, you need to import them into a "free" plan that does not require them to have a valid `PaymentMethod`. If migrating from the `SimpleBillingProvider`, your customers will not have given any `PaymentMethod` yet.
This means that when you import these subscriptions (created by the `SimpleBillingProvider`) into your new BMS, you need to import them into a "free" plan that does not require them to have a valid payment method. If migrating from the `SimpleBillingProvider`, your customers will not have provided any payment method yet.

In the product, by default, we have defined the following tiers (see: `SubscriptionTier`):

Expand Down
Binary file modified docs/images/BillingIntegration-Adoption.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/BillingIntegration-Flows.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/Sources.pptx
Binary file not shown.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/Domain.Services.Shared/IBillingStateInterpreter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public interface IBillingStateInterpreter
/// <summary>
/// Returns the provider's reference for the subscription from the <see cref="current" /> state
/// </summary>
Result<string, Error> GetSubscriptionReference(BillingProvider current);
Result<Optional<string>, Error> GetSubscriptionReference(BillingProvider current);

/// <summary>
/// Creates the initial state of the newly subscribed provider
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,32 +28,29 @@ public void WhenGetProviderName_ThenReturnsName()
}

[Fact]
public void WhenGetSubscriptionReferenceAndSubscriptionIdNotExist_ThenReturnsError()
public void WhenGetSubscriptionReferenceAndSubscriptionIdNotExist_ThenReturnsNone()
{
var provider = BillingProvider.Create(SinglePlanBillingStateInterpreter.Constants.ProviderName,
new SubscriptionMetadata { { "aname", "avalue" } })
.Value;

var result = _interpreter.GetSubscriptionReference(provider);

result.Should().BeError(ErrorCode.RuleViolation,
Resources.BasicBillingStateProxy_PropertyNotFound.Format(
SinglePlanBillingStateInterpreter.Constants.SubscriptionIdPropName,
typeof(SinglePlanBillingStateInterpreter).FullName!));
result.Value.Should().BeNone();
}

[Fact]
public void WhenGetSubscriptionReference_ThenReturnsSubscriptionId()
public void WhenGetSubscriptionReferenceAndExists_ThenReturnsSubscriptionId()
{
var provider = BillingProvider.Create(SinglePlanBillingStateInterpreter.Constants.ProviderName,
new SubscriptionMetadata
{
{ SinglePlanBillingStateInterpreter.Constants.SubscriptionIdPropName, "asubscriptionid" }
}).Value;
{
{ SinglePlanBillingStateInterpreter.Constants.SubscriptionIdPropName, "asubscriptionid" }
}).Value;

var result = _interpreter.GetSubscriptionReference(provider);

result.Should().Be("asubscriptionid");
result.Value.Should().BeSome("asubscriptionid");
}

[Fact]
Expand All @@ -76,23 +73,52 @@ public void WhenGetBuyerReference_ThenReturnsBuyerId()
{
var provider = BillingProvider.Create(SinglePlanBillingStateInterpreter.Constants.ProviderName,
new SubscriptionMetadata
{
{ SinglePlanBillingStateInterpreter.Constants.BuyerIdPropName, "abuyerid" }
}).Value;
{
{ SinglePlanBillingStateInterpreter.Constants.BuyerIdPropName, "abuyerid" }
}).Value;

var result = _interpreter.GetBuyerReference(provider);

result.Should().Be("abuyerid");
}

[Fact]
public void WhenGetBillingSubscription_ThenAlwaysReturnsBasicPlan()
public void WhenGetBillingSubscriptionAndUnsubscribed_ThenAlwaysReturnsEmptySubscription()
{
var provider = BillingProvider.Create(SinglePlanBillingStateInterpreter.Constants.ProviderName,
new SubscriptionMetadata
{
{ SinglePlanBillingStateInterpreter.Constants.SubscriptionIdPropName, "asubscriptionid" }
}).Value;
{
{ SinglePlanBillingStateInterpreter.Constants.BuyerIdPropName, "abuyerid" }
}).Value;

var result = _interpreter.GetSubscriptionDetails(provider);

result.Value.SubscriptionId.Should().Be(Identifier.Empty());
result.Value.Status.Subscription.Should().Be(BillingSubscriptionStatus.Unsubscribed);
result.Value.Status.CancelledDateUtc.Should().BeNull();
result.Value.Status.CanBeUnsubscribed.Should().BeTrue();
result.Value.Plan.Id.Should().Be(Identifier.Empty());
result.Value.Plan.IsTrial.Should().BeFalse();
result.Value.Plan.TrialEndDateUtc.Should().BeNull();
result.Value.Plan.Tier.Should().Be(BillingSubscriptionTier.Unsubscribed);
result.Value.Period.Frequency.Should().Be(0);
result.Value.Period.Unit.Should().Be(BillingFrequencyUnit.Eternity);
result.Value.Invoice.CurrencyCode.Currency.Should().Be(CurrencyCodes.Default);
result.Value.Invoice.NextUtc.Should().BeNull();
result.Value.Invoice.Amount.Should().Be(0);
result.Value.PaymentMethod.Status.Should().Be(BillingPaymentMethodStatus.Valid);
result.Value.PaymentMethod.Type.Should().Be(BillingPaymentMethodType.Other);
result.Value.PaymentMethod.ExpiresOn.Should().BeNull();
}

[Fact]
public void WhenGetBillingSubscription_ThenAlwaysReturnsStandardPlan()
{
var provider = BillingProvider.Create(SinglePlanBillingStateInterpreter.Constants.ProviderName,
new SubscriptionMetadata
{
{ SinglePlanBillingStateInterpreter.Constants.SubscriptionIdPropName, "asubscriptionid" }
}).Value;

var result = _interpreter.GetSubscriptionDetails(provider);

Expand Down Expand Up @@ -133,9 +159,9 @@ public void WhenTranslateSubscribedProviderAndMissingBuyerId_ThenReturnsError()
{
var provider = BillingProvider.Create(SinglePlanBillingStateInterpreter.Constants.ProviderName,
new SubscriptionMetadata
{
{ SinglePlanBillingStateInterpreter.Constants.SubscriptionIdPropName, "asubscriptionid" }
}).Value;
{
{ SinglePlanBillingStateInterpreter.Constants.SubscriptionIdPropName, "asubscriptionid" }
}).Value;

var result = _interpreter.SetInitialProviderState(provider);

Expand All @@ -149,9 +175,9 @@ public void WhenTranslateSubscribedProviderAndMissingSubscriptionId_ThenReturnsE
{
var provider = BillingProvider.Create(SinglePlanBillingStateInterpreter.Constants.ProviderName,
new SubscriptionMetadata
{
{ SinglePlanBillingStateInterpreter.Constants.BuyerIdPropName, "abuyerid" }
}).Value;
{
{ SinglePlanBillingStateInterpreter.Constants.BuyerIdPropName, "abuyerid" }
}).Value;

var result = _interpreter.SetInitialProviderState(provider);

Expand All @@ -165,10 +191,10 @@ public void WhenTranslateSubscribedProvider_ThenReturnsSameProvider()
{
var provider = BillingProvider.Create(SinglePlanBillingStateInterpreter.Constants.ProviderName,
new SubscriptionMetadata
{
{ SinglePlanBillingStateInterpreter.Constants.BuyerIdPropName, "abuyerid" },
{ SinglePlanBillingStateInterpreter.Constants.SubscriptionIdPropName, "asubscriptionid" }
}).Value;
{
{ SinglePlanBillingStateInterpreter.Constants.BuyerIdPropName, "abuyerid" },
{ SinglePlanBillingStateInterpreter.Constants.SubscriptionIdPropName, "asubscriptionid" }
}).Value;

var result = _interpreter.SetInitialProviderState(provider);

Expand Down
Loading

0 comments on commit 054c1c3

Please sign in to comment.