diff --git a/packages/vendure-plugin-stripe-subscription/CHANGELOG.md b/packages/vendure-plugin-stripe-subscription/CHANGELOG.md index 22662343..908a1b0c 100644 --- a/packages/vendure-plugin-stripe-subscription/CHANGELOG.md +++ b/packages/vendure-plugin-stripe-subscription/CHANGELOG.md @@ -1,3 +1,10 @@ +# 2.0.0 (2023-11-02) + +- Major refactor: ([#260](https://github.com/Pinelab-studio/pinelab-vendure-plugins/pull/260)) +- Scheduling has been taken out of this plugin. +- By default product variants are seen as monthly subscriptions +- Custom subscriptions can be defined by implementing the SubscriptionStrategy interface + # 1.4.0 (2023-09-08) - Expose proxy function to retrieve all subscriptions for current channel ([#255](https://github.com/Pinelab-studio/pinelab-vendure-plugins/pull/255)) diff --git a/packages/vendure-plugin-stripe-subscription/README.md b/packages/vendure-plugin-stripe-subscription/README.md index 2d12f9e6..ff37fcdd 100644 --- a/packages/vendure-plugin-stripe-subscription/README.md +++ b/packages/vendure-plugin-stripe-subscription/README.md @@ -2,55 +2,49 @@ ![Vendure version](https://img.shields.io/badge/dynamic/json.svg?url=https%3A%2F%2Fraw.githubusercontent.com%2FPinelab-studio%2Fpinelab-vendure-plugins%2Fmain%2Fpackage.json&query=$.devDependencies[%27@vendure/core%27]&colorB=blue&label=Built%20on%20Vendure) -A channel aware plugin that allows you to sell subscription based services or memberships through Vendure. Also support -non-subscription payments. This plugin was made in collaboration with the great people +A channel aware plugin that allows you to sell subscription based services or products through Vendure. This plugin was made in collaboration with the great people at [isoutfitters.com](https://isoutfitters.com/). -## How it works - -A few things you should know before getting started: - -- Subscriptions are defined by `Schedules`. A schedule is a blueprint for a subscription and can be reused on multiple - subscriptions. An example of a schedule is - `Billed every first of the month, for 6 months`. -- Schedules have a fixed duration. Currently, they do autorenew after this duration. The duration is used to calculate - prorations and down payment deductions. Read more on this under [Advanced features](#advanced-features) -- By connecting a `Schedule` to a ProductVariant, you turn the variant into a subscription. The price of the variant is - the price a customer pays **per interval**. - -![](docs/schedule-weekly.png) -_Managing schedules in the Admin UI_ - -![](docs/sub-product.png) -_Connecting a schedule to a product variant_ - -### Examples of schedules - -A variant with price $30,- and schedule `Duration of 6 months, billed monthly` is a subscription where the customer is -billed $30,- per month for 6 months. +- [Vendure Stripe Subscription plugin](#vendure-stripe-subscription-plugin) + - [How it works](#how-it-works) + - [Installation](#installation) + - [Storefront usage](#storefront-usage) + - [Retrieving the publishable key](#retrieving-the-publishable-key) + - [Custom subscription strategy](#custom-subscription-strategy) + - [Custom subscription inputs](#custom-subscription-inputs) + - [Multiple subscriptions per variant](#multiple-subscriptions-per-variant) + - [Caveats](#caveats) + - [Additional features](#additional-features) + - [Canceling subscriptions](#canceling-subscriptions) + - [Refunding subscriptions](#refunding-subscriptions) + - [Payment eligibility checker](#payment-eligibility-checker) + - [Contributing and dev server](#contributing-and-dev-server) -A variant with price $300 and a schedule of `Duration of 12 months, billed every 2 months` is a subscription where the -customer is billed $300 every 2 months, for a duration of 12 months. +## How it works -Currently, subscriptions auto-renew after their duration: After 12 months, the customer is again billed $300 per 2 -momnths for 12 months. +1. A customer orders a product that represents a subscription +2. During checkout, the customer is asked to pay the initial amount OR to only supply their credit card credentials when no initial payment is needed. +3. After order placement, the subscriptions will be created. Created subscriptions will be logged as history entries on the order. -## Getting started +The default strategy defines subscriptions in the following manner: -### Setup Stripe webhook +- The product variant price is used as monthly price +- The customer pays the initial amount due during the checkout +- The subscription will start one month after purchase, because the first month has been paid during checkout. -1. Go to Stripe > developers > webhooks and create a webhook to `https://your-vendure.io/stripe-subscriptions/webhook` -2. Select the following events for the webhook: `payment_intent.succeeded`, `invoice.payment_succeeded` and `invoice.payment_failed` +You can easily define your own subscriptions with a [custom subscription strategy](#custom-subscription-strategy). -## Vendure setup +## Installation -3. Add the plugin to your `vendure-config.ts` plugins and admin UI compilation: +1. Add the plugin to your `vendure-config.ts` plugins and admin UI compilation: ```ts -import { StripeSubscriptionPlugin } from 'vendure-plugin-stripe-subscription'; +import { StripeSubscriptionPlugin } from '@pinelab/vendure-plugin-stripe-subscription'; plugins: [ - StripeSubscriptionPlugin, + StripeSubscriptionPlugin.init({ + vendureHost: process.env.VENDURE_HOST!, + }), AdminUiPlugin.init({ port: 3002, route: 'admin', @@ -62,39 +56,50 @@ plugins: [ ]; ``` -5. Run a migration to add the `Schedule` entity and custom fields to the database. -6. Start the Vendure server and login to the admin UI -7. Go to `Settings > Subscriptions` and create a Schedule. -8. Create a variant and select a schedule in the variant detail screen in the admin UI. -9. Create a payment method with the code `stripe-subscription-payment` and select `stripe-subscription` as handler. You can (and should) have only 1 payment method with the Stripe Subscription handler per channel. -10. Set your API key from Stripe in the apiKey field. -11. Get the webhook secret from you Stripe dashboard and save it on the payment method. +2. Start the Vendure server and login to the admin UI +3. Create a payment method and select `Stripe Subscription` as handler +4. Fill in your `API key`. `Publishable key` and `Webhook secret` can be left empty at first. +5. Save the payment method and refresh the Admin UI screen. +6. The `Webhook secret` field should now have a value. This means webhooks have been created in your Stripe account. If not, check the server logs. +7. You can (and should) have only 1 payment method with the Stripe Subscription handler per channel. ## Storefront usage -1. From your storefront, add the subscription variant to your order -2. Add a shipping address and a shipping method to the order (mandatory for all orders). -3. Call the graphql mutation `createStripeSubscriptionIntent` to receive the Payment intent token. -4. Use this token to display the Stripe form on your storefront. See - the [Stripe docs](https://stripe.com/docs/payments/accept-a-payment?platform=web&ui=elements#set-up-stripe.js) on how - to accomplish that. -5. During the checkout the user is only charged any potential down payment or proration ( - see [Advanced features](#advanced-features)). The recurring charges will occur on the start of the schedule. For - paid-up-front schedules the customer pays the full amount during checkout -6. Have the customer fill out his payment details. -7. Vendure will create the subscriptions after the intent has successfully been completed by the customer. -8. The order will be settled by Vendure when the subscriptions are created. +1. On the product detail page of your subscription product, you can preview the subscription for a given variant with this query: -It's important to inform your customers what you will be billing them in the -future: https://stripe.com/docs/payments/setup-intents#mandates +```graphql +{ + previewStripeSubscriptions(productVariantId: 1) { + name + amountDueNow + variantId + priceIncludesTax + recurring { + amount + interval + intervalCount + startDate + endDate + } + } +} +``` -![](docs/subscription-events.png) -_After order settlement you can view the subscription details on the order history_ +2. The same can be done for all variants of a product with the query `previewStripeSubscriptionsForProduct` +3. Add the item to cart with the default `AddItemToOrder` mutation. +4. Add a shipping address and a shipping method to the order (mandatory for all orders). +5. You can create `createStripeSubscriptionIntent` to receive a client secret. +6. :warning: Please make sure you render the correct Stripe elements: A created intent can be a `PaymentIntent` or a `SetupIntent`. +7. Use this token to display the Stripe form elements on your storefront. See + the [Stripe docs](https://stripe.com/docs/payments/accept-a-payment?platform=web&ui=elements#set-up-stripe.js) for more information. +8. The customer can now enter his credit card credentials. +9. Vendure will create the subscriptions in the background, after the intent has successfully been completed by the customer. +10. The order will be settled by Vendure when the subscriptions are created. -![](docs/sequence.png) -_Subscriptions are created in the background, after a customer has finished the checkout_ +It's important to inform your customers what you will be billing them in the +future: https://stripe.com/docs/payments/setup-intents#mandates -#### Retrieving the publishable key +### Retrieving the publishable key You can optionally supply your publishable key in your payment method handler, so that you can retrieve it using the `eligiblePaymentMethods` query: @@ -108,253 +113,213 @@ You can optionally supply your publishable key in your payment method handler, s } ``` -## Order with a total of €0 - -With subscriptions, it can be that your order totals to €0, because, for example, you start charging your customer starting next month. -In case of an order being €0, a verification fee of €1 is added, because payment_intents with €0 are not allowed by Stripe. - -## Canceling subscriptions - -You can cancel a subscription by canceling the corresponding order line of an order. The subscription will be canceled before the next billing cycle using Stripe's `cancel_at_period_end` parameter. - -## Refunding subscriptions - -Only initial payments of subscriptions can be refunded. Any future payments should be refunded via the Stripe dashboard. - -# Advanced features - -Features you can use, but don't have to! - -## Payment eligibility checker - -You can use the payment eligibility checker `has-stripe-subscription-products-checker` if you want customers that don't have subscription products in their order to use a different payment method. The `has-stripe-subscription-products-checker` makes your payment method not eligible if it does not contain any subscription products. - -The checker is added automatically, you can just select it via the Admin UI when creating or updating a payment method. - -## Down payments - -You can define down payments to a schedule, to have a customer pay a certain amount up front. The paid amount is then deducted from the recurring charges. - -Example: -We have a schedule + variant where the customer normally pays $90 a month for 6 months. We set a down payment of $180, so the customer pays $180 during checkout. -The customer will now be billed $60 a month, because he already paid $180 up front: $180 / 6 months = $30, so we deduct the $30 from every month. - -A down payment is created as separate subscription in Stripe. In the example above, a subscription will be created that charges the customer $180 every 6 months, -because the down payment needs to be paid again after renewal +## Custom subscription strategy -## Paid up front +You can define your own subscriptions by implementing the `StripeSubscriptionStrategy`: -Schedules can be defined as 'Paid up front'. This means the customer will have to pay the total value of the -subscription during the checkout. Paid-up-front subscriptions can not have down payments, because it's already one big -down payment. +```ts +import { SubscriptionStrategy } from '@pinelab/vendure-plugin-stripe-subscription'; +import { RequestContext, Injector, ProductVariant, Order } from '@vendure/core'; -Example: -![](docs/schedule-paid-up-front.png) -When we connect the schedule above to a variant with price $540,-, the user will be prompted to pay $540,- during -checkout. The schedules start date is **first of the month**, so a subscription is created to renew the $540,- in 6 -months from the first of the month. E.g. the customer buys this subscription on January 15 and pays $540,- during -checkout. The subscription's start date is February 1, because that's the first of the next month. +/** + * This example creates a subscription that charges the customer the price of the variant, every 4 weeks + */ +export class MySubscriptionStrategy implements SubscriptionStrategy { + isSubscription(ctx: RequestContext, variant: ProductVariant): boolean { + // This example treats all products as subscriptions + return true; + } -The customer will be billed $540 again automatically on July 1, because that's 6 months (the duration) from the start -date of the subscription. + defineSubscription( + ctx: RequestContext, + injector: Injector, + productVariant: ProductVariant, + order: Order, + orderLineCustomFields: { [key: string]: any }, + quantity: number + ): Subscription { + return { + name: `Subscription ${productVariant.name}`, + priceIncludesTax: productVariant.listPriceIncludesTax, + amountDueNow: productVariant.listPrice, + recurring: { + amount: productVariant.listPrice, + interval: 'week', + intervalCount: 4, + startDate: new Date(), + }, + }; + } -## Prorations + // This is used to preview the subscription in the storefront, without adding them to cart + previewSubscription( + ctx: RequestContext, + injector: Injector, + // Custom inputs can be passed into the preview method via the storefront + customInputs: any, + productVariant: ProductVariant + ): Subscription { + return { + name: `Subscription ${productVariant.name}`, + priceIncludesTax: productVariant.listPriceIncludesTax, + amountDueNow: productVariant.listPrice, + recurring: { + amount: productVariant.listPrice, + interval: 'week', + intervalCount: 4, + startDate: new Date(), + }, + }; + } +} +``` -In the example above, the customer will also be billed for the remaining 15 days from January 15 to February 1, this is -called proration. +You can then pass the strategy into the plugin during initialization in `vendure-config.ts`: -Proration can be configured on a schedule. With `useProration=false` a customer isn't charged for the remaining days during checkout. +```ts + StripeSubscriptionPlugin.init({ + vendureHost: process.env.VENDURE_HOST!, + subscriptionStrategy: new MySubscriptionStrategy(), + }), +``` -Proration is calculated on a yearly basis. E.g, in the example above: $540 is for a duration of 6 months, that means -$1080 for the full year. The day rate of that subscription will then be 1080 / 365 = $2,96 a day. When the customer buys -the subscription on January 15, he will be billed $44,40 proration for the remaining 15 days. +### Custom subscription inputs -## Storefront defined start dates +You can pass custom inputs to your strategy, to change how a subscription is defined, for example by having a selectable start date: -A customer can decide to start the subscription on January 17, to pay less proration, because there are now only 13 days -left until the first of February. This can be done in the storefront with the following query: +1. Define a custom field on an order line named `subscriptionStartDate` +2. When previewing a subscription for a product, you can pass a `subscriptionStartDate` to your strategy: ```graphql -mutation { - addItemToOrder( +{ + previewStripeSubscriptionsForProduct( productVariantId: 1 - quantity: 1 - customFields: { startDate: "2023-01-31T09:18:28.533Z" } + customInputs: { subscriptionStartDate: "2024-01-01" } ) { - ... on Order { - id + name + amountDueNow + variantId + priceIncludesTax + recurring { + amount + interval + intervalCount + startDate + endDate } } } ``` -## Storefront defined down payments - -A customer can also choose to pay a higher down payment, to lower the recurring costs of a subscription. +3. In you custom strategy, you would handle the custom input: -Example: -A customer buys a subscription that has a duration of 6 months, and costs $90 per month. The customer can choose to pay -a down payment of $270,- during checkout to lower to monthly price. The $270 down payment will be deducted from the -monthly price: 270 / 6 months = $45. With the down payment of $270, customer will now be billed 90 - 45 = $45,- per month -for the next 6 months. - -Down payments can be added via a custom field on the order line: - -```graphql -mutation { - addItemToOrder( - productVariantId: 1 - quantity: 1 - customFields: { down payment: 27000 } - ) { - ... on Order { - id - } +```ts + previewSubscription( + ctx: RequestContext, + injector: Injector, + customInputs: { subscriptionStartDate: string }, + productVariant: ProductVariant + ): Subscription { + return { + name: `Subscription ${productVariant.name}`, + priceIncludesTax: productVariant.listPriceIncludesTax, + amountDueNow: productVariant.listPrice, + recurring: { + amount: productVariant.listPrice, + interval: 'week', + intervalCount: 4, + startDate: new Date(customInputs.subscriptionStartDate), + }, + }; } } ``` -Down payments can never be lower that the amount set in the schedule, and never higher than the total value of a -subscription. - -### Preview pricing calculations +4. When adding a product to cart, make sure you also set the `subscriptionStartDate` on the order line, so that you can access it in the `defineSubscription` method of your strategy: -You can preview the pricing model of a subscription without adding it to cart with the following query on the shop-api: - -```graphql -{ - getStripeSubscriptionPricing( - input: { - productVariantId: 1 - # Optional dynamic start date - startDate: "2022-12-25T00:00:00.000Z" - # Optional dynamic down payment - downpayment: 1200 - } - ) { - downpayment - pricesIncludeTax - totalProratedAmount - proratedDays - recurringPrice - originalRecurringPrice - interval - intervalCount - amountDueNow - subscriptionStartDate - schedule { - id - name - downpayment - pricesIncludeTax - durationInterval - durationCount - startMoment - paidUpFront - billingCount - billingInterval - } +```ts + defineSubscription( + ctx: RequestContext, + injector: Injector, + productVariant: ProductVariant, + order: Order, + orderLineCustomFields: { [key: string]: any }, + quantity: number + ): Subscription { + return { + name: `Subscription ${productVariant.name}`, + priceIncludesTax: productVariant.listPriceIncludesTax, + amountDueNow: productVariant.listPrice, + recurring: { + amount: productVariant.listPrice, + interval: 'week', + intervalCount: 4, + startDate: new Date(orderLineCustomFields.subscriptionStartDate), + }, + }; } -} ``` -`Downpayment` and `startDate` are optional parameters. Without them, the defaults defined by the schedule will be used. +### Multiple subscriptions per variant -### Get subscription pricing details per order line +It's possible to define multiple subscriptions per product. For example when you want to support down payments or yearly contributions. -You can also get the subscription and Schedule pricing details per order line with the following query: +Example: A customer pays $90 a month, but is also required to pay a yearly fee of $150: -```graphql -{ - activeOrder { - id - code - lines { - subscriptionPricing { - downpayment - totalProratedAmount - proratedDays - dayRate - recurringPrice - interval - intervalCount - amountDueNow - subscriptionStartDate - schedule { - id - name - downpayment - durationInterval - durationCount - startMoment - paidUpFront - billingCount - billingInterval - } - } - } +```ts + defineSubscription( + ctx: RequestContext, + injector: Injector, + productVariant: ProductVariant, + ): Subscription { + return [ + { + name: `Monthly fee`, + priceIncludesTax: productVariant.listPriceIncludesTax, + amountDueNow: 0, + recurring: { + amount: 9000, + interval: 'month', + intervalCount: 1, + startDate: new Date(), + }, + }, { + name: `yearly fee`, + priceIncludesTax: productVariant.listPriceIncludesTax, + amountDueNow: 0, + recurring: { + amount: 15000, + interval: 'year', + intervalCount: 1, + startDate: new Date(), + }, + } + ]; + } ``` -### Discount subscription payments +## Caveats -Example of a discount on subscription payments: +1. This plugin overrides any set `OrderItemCalculationStrategy`. The strategy in this plugin is used for calculating the + amount due for a subscription, if the variant is a subscription. For non-subscription variants, Vendure's default + order line calculation is used. Only 1 strategy can be used per Vendure instance, so any other + OrderItemCalculationStrategies are overwritten by this plugin. -- We have a subscription that will cost $30 a month, but has the promotion `Discount future subscription payments by 10%` applied -- The actual monthly price of the subscription will be $27, forever. +## Additional features -There are some built in discounts that work on future payments of a subscription. You can select the under Promotion Actions in the Admin UI. +### Canceling subscriptions -`StripeSubscriptionPricing.originalrecurringPrice` will have the non-discounted subscription price, while `StripeSubscriptionPricing.recurringPrice` will have the final discounted price. +You can cancel a subscription by canceling the corresponding order line of an order. The subscription will be canceled before the next billing cycle using Stripe's `cancel_at_period_end` parameter. -### Custom future payments promotions +### Refunding subscriptions -You can implement your own custom discounts that will apply to future payments. These promotions **do not** affect the actual order price, only future payments (the actual subscription price)! +Only initial payments of subscriptions can be refunded. Any future payments should be refunded via the Stripe dashboard. -The `SubscriptionPromotionAction` will discount all subscriptions in an order. +### Payment eligibility checker -```ts -// Example fixed discount promotion -import { SubscriptionPromotionAction } from 'vendure-plugin-stripe-subscription'; +You can use the payment eligibility checker `has-stripe-subscription-products-checker` if you to use a different payment method for orders without subscriptions. The `has-stripe-subscription-products-checker` makes your payment method not eligible if it does not contain any subscription products. -/** - * Discount all subscription payments by a percentage. - */ -export const discountAllSubscriptionsByPercentage = - new SubscriptionPromotionAction({ - code: 'discount_all_subscription_payments_example', - description: [ - { - languageCode: LanguageCode.en, - value: 'Discount future subscription payments by { discount } %', - }, - ], - args: { - discount: { - type: 'int', - ui: { - component: 'number-form-input', - suffix: '%', - }, - }, - }, - async executeOnSubscription( - ctx, - currentSubscriptionPrice, - orderLine, - args - ) { - const discount = currentSubscriptionPrice * (args.discount / 100); - return discount; - }, - }); -``` - -## Caveats - -1. This plugin overrides any set OrderItemCalculationStrategies. The strategy in this plugin is used for calculating the - amount due for a subscription, if the variant is a subscription. For non-subscription variants, the normal default - orderline calculation is used. Only 1 strategy can be used per Vendure instance, so any other - OrderItemCalculationStrategies are overwritten by this plugin. +The checker is added automatically, you can just select it via the Admin UI when creating or updating a payment method. ### Contributing and dev server @@ -362,10 +327,15 @@ You can locally test this plugin by checking out the source. 1. Create a .env file with the following contents: -``` -STRIPE_APIKEY=sk_test_ -STRIPE_PUBLISHABLE_KEY=pk_test_ +```shell + +STRIPE_APIKEY=sk_test_**** +STRIPE_PUBLISHABLE_KEY=pk_test_**** +VENDURE_HOST=https://280n-dn27839.ngrok-free.app + ``` 2. Run `yarn start` 3. Go to `http://localhost:3050/checkout` to view the Stripe checkout +4. Use a [Stripe test card](https://stripe.com/docs/testing) as credit card details. +5. See the order being `PaymentSettled` in the admin. diff --git a/packages/vendure-plugin-stripe-subscription/codegen.yml b/packages/vendure-plugin-stripe-subscription/codegen.yml index 6bdd6d76..79b74c1b 100644 --- a/packages/vendure-plugin-stripe-subscription/codegen.yml +++ b/packages/vendure-plugin-stripe-subscription/codegen.yml @@ -1,12 +1,12 @@ -schema: 'src/**/*.ts' -documents: 'src/ui/queries.ts' +schema: 'src/api/graphql-schema.ts' generates: - ./src/ui/generated/graphql.ts: + ./src/api/generated/graphql.ts: plugins: - typescript - typescript-operations - typed-document-node config: + enumsAsTypes: true avoidOptionals: false scalars: DateTime: Date diff --git a/packages/vendure-plugin-stripe-subscription/package.json b/packages/vendure-plugin-stripe-subscription/package.json index ef03c11c..d380243a 100644 --- a/packages/vendure-plugin-stripe-subscription/package.json +++ b/packages/vendure-plugin-stripe-subscription/package.json @@ -1,6 +1,6 @@ { "name": "@pinelab/vendure-plugin-stripe-subscription", - "version": "1.4.0", + "version": "2.0.0", "description": "Vendure plugin for selling subscriptions via Stripe", "author": "Martijn van de Brug ", "homepage": "https://pinelab-plugins.com/", diff --git a/packages/vendure-plugin-stripe-subscription/src/api/graphql-schema.ts b/packages/vendure-plugin-stripe-subscription/src/api/graphql-schema.ts new file mode 100644 index 00000000..2b2c5d18 --- /dev/null +++ b/packages/vendure-plugin-stripe-subscription/src/api/graphql-schema.ts @@ -0,0 +1,62 @@ +import { gql } from 'graphql-tag'; + +/** + * Needed for gql codegen + */ +const _codegenAdditions = gql` + scalar DateTime + scalar JSON +`; + +export const shopSchemaExtensions = gql` + enum StripeSubscriptionInterval { + week + month + year + } + + type StripeSubscription { + name: String! + variantId: ID! + amountDueNow: Int! + priceIncludesTax: Boolean! + recurring: StripeSubscriptionRecurringPayment! + } + + type StripeSubscriptionRecurringPayment { + amount: Int! + interval: StripeSubscriptionInterval! + intervalCount: Int! + startDate: DateTime! + endDate: DateTime + } + + enum StripeSubscriptionIntentType { + PaymentIntent + SetupIntent + } + + type StripeSubscriptionIntent { + clientSecret: String! + intentType: StripeSubscriptionIntentType! + } + + extend type PaymentMethodQuote { + stripeSubscriptionPublishableKey: String + } + + extend type Query { + previewStripeSubscriptions( + productVariantId: ID! + customInputs: JSON + ): [StripeSubscription!]! + previewStripeSubscriptionsForProduct( + productId: ID! + customInputs: JSON + ): [StripeSubscription!]! + } + + extend type Mutation { + createStripeSubscriptionIntent: StripeSubscriptionIntent! + } +`; diff --git a/packages/vendure-plugin-stripe-subscription/src/api/graphql-schemas.ts b/packages/vendure-plugin-stripe-subscription/src/api/graphql-schemas.ts deleted file mode 100644 index 7cc14cf1..00000000 --- a/packages/vendure-plugin-stripe-subscription/src/api/graphql-schemas.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { gql } from 'graphql-tag'; - -/** - * Needed for gql codegen - */ -const _scalar = gql` - scalar DateTime -`; - -const _interface = gql` - interface Node { - id: ID! - createdAt: DateTime - } - interface PaginatedList { - items: [Node!]! - totalItems: Int! - } -`; - -const sharedTypes = gql` - enum SubscriptionInterval { - week - month - } - enum SubscriptionStartMoment { - start_of_billing_interval - end_of_billing_interval - time_of_purchase - fixed_startdate - } - """ - For codegen to work this must implement Node - """ - type StripeSubscriptionSchedule implements Node { - id: ID! - createdAt: DateTime - updatedAt: DateTime - name: String! - downpayment: Int! - pricesIncludeTax: Boolean! - durationInterval: SubscriptionInterval! - durationCount: Int! - startMoment: SubscriptionStartMoment! - paidUpFront: Boolean! - billingInterval: SubscriptionInterval! - billingCount: Int! - fixedStartDate: DateTime - useProration: Boolean - autoRenew: Boolean - } - """ - For codegen to work this must implement Node - """ - type StripeSubscriptionPayment implements Node { - id: ID! - createdAt: DateTime - updatedAt: DateTime - collectionMethod: String - charge: Int - currency: String - orderCode: String - channelId: ID - eventType: String - subscriptionId: String - } - input UpsertStripeSubscriptionScheduleInput { - id: ID - name: String! - downpayment: Int! - durationInterval: SubscriptionInterval! - durationCount: Int! - startMoment: SubscriptionStartMoment! - billingInterval: SubscriptionInterval! - billingCount: Int! - fixedStartDate: DateTime - useProration: Boolean - autoRenew: Boolean - } - extend type OrderLine { - subscriptionPricing: StripeSubscriptionPricing - } - type StripeSubscriptionPricing { - variantId: String! - pricesIncludeTax: Boolean! - downpayment: Int! - totalProratedAmount: Int! - proratedDays: Int! - dayRate: Int! - """ - The recurring price of the subscription, including discounts and tax. - """ - recurringPrice: Int! - """ - The original recurring price of the subscription, including tax, without discounts applied. - """ - originalRecurringPrice: Int! - interval: SubscriptionInterval! - intervalCount: Int! - amountDueNow: Int! - subscriptionStartDate: DateTime! - subscriptionEndDate: DateTime - schedule: StripeSubscriptionSchedule! - } -`; - -export const shopSchemaExtensions = gql` - ${sharedTypes} - input StripeSubscriptionPricingInput { - productVariantId: ID! - startDate: DateTime - downpayment: Int - } - - extend type PaymentMethodQuote { - stripeSubscriptionPublishableKey: String - } - - extend type Query { - """ - Preview the pricing model of a given subscription. - Start date and downpayment are optional: if not supplied, the subscriptions default will be used. - """ - stripeSubscriptionPricing( - input: StripeSubscriptionPricingInput - ): StripeSubscriptionPricing - stripeSubscriptionPricingForProduct( - productId: ID! - ): [StripeSubscriptionPricing!]! - } - extend type Mutation { - createStripeSubscriptionIntent: String! - } -`; - -export const adminSchemaExtensions = gql` - ${sharedTypes} - - extend enum HistoryEntryType { - STRIPE_SUBSCRIPTION_NOTIFICATION - } - - """ - For codegen to work this must be non-empty - """ - input StripeSubscriptionPaymentListOptions { - skip: Int - } - - """ - For codegen to work this must be non-empty - """ - input StripeSubscriptionScheduleListOptions { - skip: Int - } - - type StripeSubscriptionPaymentList implements PaginatedList { - items: [StripeSubscriptionPayment!]! - totalItems: Int! - } - - type StripeSubscriptionScheduleList implements PaginatedList { - items: [StripeSubscriptionSchedule!]! - totalItems: Int! - } - - extend type Query { - stripeSubscriptionSchedules( - options: StripeSubscriptionScheduleListOptions - ): StripeSubscriptionScheduleList! - stripeSubscriptionPayments( - options: StripeSubscriptionPaymentListOptions - ): StripeSubscriptionPaymentList! - } - - extend type Mutation { - upsertStripeSubscriptionSchedule( - input: UpsertStripeSubscriptionScheduleInput! - ): StripeSubscriptionSchedule! - deleteStripeSubscriptionSchedule(scheduleId: ID!): Boolean - } -`; diff --git a/packages/vendure-plugin-stripe-subscription/src/api/pricing.helper.ts b/packages/vendure-plugin-stripe-subscription/src/api/pricing.helper.ts deleted file mode 100644 index 2241858d..00000000 --- a/packages/vendure-plugin-stripe-subscription/src/api/pricing.helper.ts +++ /dev/null @@ -1,347 +0,0 @@ -import { - Logger, - Promotion, - RequestContext, - UserInputError, -} from '@vendure/core'; -import { getConfig } from '@vendure/core/dist/config/config-helpers'; -import { - addMonths, - addWeeks, - differenceInDays, - endOfMonth, - endOfWeek, - startOfDay, - startOfMonth, - startOfWeek, -} from 'date-fns'; -import { loggerCtx } from '../constants'; -import { - StripeSubscriptionPricing, - StripeSubscriptionPricingInput, - SubscriptionInterval, - SubscriptionStartMoment, -} from '../ui/generated/graphql'; -import { Schedule } from './schedule.entity'; -import { - OrderLineWithSubscriptionFields, - VariantWithSubscriptionFields, -} from './subscription-custom-fields'; -import { SubscriptionPromotionAction } from './subscription.promotion'; - -export type VariantForCalculation = Pick< - VariantWithSubscriptionFields, - 'id' | 'listPrice' | 'customFields' ->; - -/** - * Calculate subscription pricing based on variants, schedules and optional input - * Doesn't apply discounts yet! - * - * @param ctx - * @param rawSubscriptionPrice - * @param schedule The schedule that should be used for the Subscription - * @param input - * @returns - */ -export function calculateSubscriptionPricing( - ctx: RequestContext, - rawSubscriptionPrice: number, - schedule: Schedule, - input?: Pick -): Omit { - let downpayment = schedule.downpayment; - if (input?.downpayment || input?.downpayment === 0) { - downpayment = input.downpayment; - } - if (schedule.paidUpFront && schedule.downpayment) { - // Paid-up-front subscriptions cant have downpayments - throw new UserInputError( - `Paid-up-front subscriptions can not have downpayments!` - ); - } - if (schedule.paidUpFront && downpayment) { - throw new UserInputError( - `You can not use downpayments with Paid-up-front subscriptions` - ); - } - const billingsPerDuration = getBillingsPerDuration(schedule); - const totalSubscriptionPrice = - rawSubscriptionPrice * billingsPerDuration + schedule.downpayment; - if (downpayment > totalSubscriptionPrice) { - throw new UserInputError( - `Downpayment cannot be higher than the total subscription value, which is (${printMoney( - totalSubscriptionPrice - )})` - ); - } - if (downpayment < schedule.downpayment) { - throw new UserInputError( - `Downpayment cannot be lower than schedules default downpayment, which is (${printMoney( - schedule.downpayment - )})` - ); - } - if ( - schedule.startMoment === SubscriptionStartMoment.FixedStartdate && - input?.startDate - ) { - throw new UserInputError( - 'You cannot choose a custom startDate for schedules with a fixed start date' - ); - } - const dayRate = getDayRate( - totalSubscriptionPrice, - schedule.durationInterval!, - schedule.durationCount! - ); - const now = new Date(); - let subscriptionStartDate = getNextStartDate( - now, - schedule.billingInterval, - schedule.startMoment, - schedule.fixedStartDate - ); - let subscriptionEndDate = undefined; - if (!schedule.autoRenew) { - // Without autorenewal we need an endDate - subscriptionEndDate = getEndDate( - subscriptionStartDate, - schedule.startMoment, - schedule.durationInterval, - schedule.durationCount - ); - } - let proratedDays = 0; - let totalProratedAmount = 0; - if (schedule.useProration) { - proratedDays = getDaysUntilNextStartDate( - input?.startDate || now, - subscriptionStartDate - ); - totalProratedAmount = proratedDays * dayRate; - } - let amountDueNow = downpayment + totalProratedAmount; - let recurringPrice = Math.floor( - (totalSubscriptionPrice - downpayment) / billingsPerDuration - ); - if (schedule.paidUpFront) { - // User pays for the full membership now - amountDueNow = rawSubscriptionPrice + totalProratedAmount; - recurringPrice = rawSubscriptionPrice; - // If paid up front, move the startDate to next cycle, because the first cycle has been paid up front. This needs to happen AFTER proration calculation - subscriptionStartDate = getNextCyclesStartDate( - new Date(), - schedule.startMoment, - schedule.durationInterval, - schedule.durationCount - ); - } - return { - downpayment: downpayment, - totalProratedAmount: totalProratedAmount, - proratedDays: proratedDays, - dayRate, - pricesIncludeTax: ctx.channel.pricesIncludeTax, - recurringPrice: recurringPrice, - interval: schedule.billingInterval, - intervalCount: schedule.billingCount, - amountDueNow, - subscriptionStartDate, - subscriptionEndDate, - schedule: cloneSchedule(ctx, schedule), - }; -} - -/** - * Calculate the discounted recurring price based on given promotions - */ -export async function applySubscriptionPromotions( - ctx: RequestContext, - recurringPrice: number, - orderLine: OrderLineWithSubscriptionFields, - promotions: Promotion[] -): Promise { - let discountedRecurringPrice = recurringPrice; - const allActions = getConfig().promotionOptions.promotionActions || []; - for (const promotion of promotions) { - for (const action of promotion.actions) { - const promotionAction = allActions.find((a) => a.code === action.code); - if (promotionAction instanceof SubscriptionPromotionAction) { - const discount = await promotionAction.executeOnSubscription( - ctx, - discountedRecurringPrice, - orderLine, - action.args - ); - const newDiscountedPrice = discountedRecurringPrice + discount; - Logger.info( - `Discounted recurring price from ${discountedRecurringPrice} to ${newDiscountedPrice} for promotion ${promotion.name}`, - loggerCtx - ); - discountedRecurringPrice = newDiscountedPrice; - } - } - } - return discountedRecurringPrice; -} - -/** - * Calculate day rate based on the total price and duration of the subscription - * Example: $200 per 6 months - * = $400 per 12 months - * $400 / 365 = $1,10 per day - */ -export function getDayRate( - totalPrice: number, - durationInterval: SubscriptionInterval, - durationCount: number -): number { - let intervalsPerYear = 12; // Default is 1 month - if (durationInterval === SubscriptionInterval.Week) { - intervalsPerYear = 52; - } - const pricePerYear = (intervalsPerYear / durationCount) * totalPrice; - return Math.round(pricePerYear / 365); -} - -export function getDaysUntilNextStartDate( - now: Date, - nextStartDate: Date -): number { - const startOfToday = startOfDay(now); - return differenceInDays(nextStartDate, startOfToday); -} - -/** - * Get the next startDate for a given start moment (first or last of the Interval). Always returns the middle of the day for billing - */ -export function getNextStartDate( - now: Date, - interval: SubscriptionInterval, - startMoment: SubscriptionStartMoment, - fixedStartDate?: Date -): Date { - if (startMoment === SubscriptionStartMoment.TimeOfPurchase) { - return now; - } else if (startMoment === SubscriptionStartMoment.FixedStartdate) { - if (!fixedStartDate) { - throw Error( - `With a 'Fixed start date' startMoment, the 'fixedStartDate' argument is mandatory` - ); - } - return fixedStartDate; - } - let nextStartDate = new Date(); - if (interval === SubscriptionInterval.Month) { - if (startMoment === SubscriptionStartMoment.StartOfBillingInterval) { - const nextMonth = addMonths(now, 1); - nextStartDate = startOfMonth(nextMonth); - } else if (startMoment === SubscriptionStartMoment.EndOfBillingInterval) { - nextStartDate = endOfMonth(now); - } else { - throw Error( - `Unhandled combination of startMoment=${startMoment} and interval=${interval}` - ); - } - } else if (interval === SubscriptionInterval.Week) { - if (startMoment === SubscriptionStartMoment.StartOfBillingInterval) { - const nextWeek = addWeeks(now, 1); - nextStartDate = startOfWeek(nextWeek); - } else if (startMoment === SubscriptionStartMoment.EndOfBillingInterval) { - nextStartDate = endOfWeek(now); - } else { - throw Error( - `Unhandled combination of startMoment=${startMoment} and interval=${interval}` - ); - } - } - return getMiddleOfDay(nextStartDate); -} - -/** - * Get the next cycles startDate. Used for paid-up-front subscriptions, where a user already paid for the first cycle - * and we need the next cycles start date - */ -export function getNextCyclesStartDate( - now: Date, - startMoment: SubscriptionStartMoment, - interval: SubscriptionInterval, - intervalCount: number, - fixedStartDate?: Date -): Date { - const endDate = getEndDate(now, startMoment, interval, intervalCount); - return getNextStartDate(endDate, interval, startMoment, fixedStartDate); -} - -/** - * Get the endDate of a subscription based on the duration - */ -export function getEndDate( - startDate: Date, - startMoment: SubscriptionStartMoment, - interval: SubscriptionInterval, - intervalCount: number -): Date { - let endDate = new Date(startDate); - if (interval === SubscriptionInterval.Month) { - endDate = addMonths(endDate, intervalCount); - } else { - // Week - endDate = addWeeks(endDate, intervalCount); - } - return endDate; -} - -/** - * Return the middle of the day (13:00) for dates, because that makes more sense for billing - */ -export function getMiddleOfDay(date: Date): Date { - const start = new Date(date); - start.setHours(13, 0, 0, 0); - return start; -} - -/** - * Get the number of billings per full duration of the schedule - */ -export function getBillingsPerDuration( - schedule: Pick< - Schedule, - 'durationInterval' | 'durationCount' | 'billingInterval' | 'billingCount' - > -): number { - if ( - schedule.durationInterval === SubscriptionInterval.Week && - schedule.billingInterval === SubscriptionInterval.Month - ) { - throw new UserInputError( - `Billing interval must be greater or equal to duration interval. E.g. billing cannot occur monthly for a schedule with a duration of 3 weeks.` - ); - } - if (schedule.billingInterval === schedule.durationInterval) { - return schedule.durationCount / schedule.billingCount; - } - if ( - schedule.billingInterval === SubscriptionInterval.Week && - schedule.durationInterval === SubscriptionInterval.Month - ) { - return (4 / schedule.billingCount) * schedule.durationCount; - } - throw Error( - `Can not calculate billingsPerDurations for billingInterval ${schedule.billingInterval} and durationInterval ${schedule.durationInterval}` - ); -} - -/** - * Yes, it's real, this helper function prints money for you! - */ -export function printMoney(amount: number): string { - return (amount / 100).toFixed(2); -} - -export function cloneSchedule(ctx: RequestContext, schedule: Schedule) { - return Object.assign(schedule, { - pricesIncludeTax: ctx.channel.pricesIncludeTax, - }); -} diff --git a/packages/vendure-plugin-stripe-subscription/src/api/schedule.entity.ts b/packages/vendure-plugin-stripe-subscription/src/api/schedule.entity.ts deleted file mode 100644 index 2e28a559..00000000 --- a/packages/vendure-plugin-stripe-subscription/src/api/schedule.entity.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { DeepPartial, VendureEntity } from '@vendure/core'; -import { - SubscriptionInterval, - SubscriptionStartMoment, -} from '../ui/generated/graphql'; -import { Column, Entity } from 'typeorm'; - -@Entity() -export class Schedule extends VendureEntity { - constructor(input?: DeepPartial) { - super(input); - } - - @Column('varchar', { nullable: false }) - name!: string; - - @Column('varchar', { nullable: false }) - channelId!: string; - - @Column({ type: 'integer', nullable: false }) - downpayment!: number; - - @Column('simple-enum', { nullable: false, enum: SubscriptionInterval }) - durationInterval!: SubscriptionInterval; - - @Column({ type: 'integer', nullable: false }) - durationCount!: number; - - @Column('simple-enum', { nullable: false, enum: SubscriptionStartMoment }) - startMoment!: SubscriptionStartMoment; - - @Column('simple-enum', { nullable: false, enum: SubscriptionInterval }) - billingInterval!: SubscriptionInterval; - - @Column({ type: 'integer', nullable: false }) - billingCount!: number; - - @Column({ type: Date, nullable: true }) - fixedStartDate?: Date; - - @Column({ type: 'boolean', nullable: true }) - useProration?: boolean; - - @Column({ type: 'boolean', nullable: true }) - autoRenew?: boolean; - - /** - * When billing and duration cycles are the same, this is a paid-up-front schedule - * and the user pays the total amount of a subscription up front - */ - get paidUpFront(): boolean { - return ( - this.billingInterval.valueOf() == this.durationInterval.valueOf() && - this.billingCount.valueOf() == this.durationCount.valueOf() - ); - } -} diff --git a/packages/vendure-plugin-stripe-subscription/src/api/schedule.service.ts b/packages/vendure-plugin-stripe-subscription/src/api/schedule.service.ts deleted file mode 100644 index 8aeacd4a..00000000 --- a/packages/vendure-plugin-stripe-subscription/src/api/schedule.service.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { - ID, - ListQueryBuilder, - RequestContext, - TransactionalConnection, - UserInputError, -} from '@vendure/core'; -import { - StripeSubscriptionSchedule, - StripeSubscriptionScheduleList, - StripeSubscriptionScheduleListOptions, - SubscriptionStartMoment, - UpsertStripeSubscriptionScheduleInput, -} from '../ui/generated/graphql'; -import { cloneSchedule } from './pricing.helper'; -import { Schedule } from './schedule.entity'; - -@Injectable() -export class ScheduleService { - constructor( - private listQueryBuilder: ListQueryBuilder, - private connection: TransactionalConnection - ) {} - - async getSchedules( - ctx: RequestContext, - options: StripeSubscriptionScheduleListOptions - ): Promise { - return this.listQueryBuilder - .build(Schedule, options, { ctx }) - .getManyAndCount() - .then(([items, totalItems]) => ({ - items: items.map((schedule) => { - return cloneSchedule(ctx, schedule); - }), - totalItems, - })); - } - - async upsert( - ctx: RequestContext, - input: UpsertStripeSubscriptionScheduleInput - ): Promise { - this.validate(input); - const { id } = await this.connection.getRepository(ctx, Schedule).save({ - ...input, - channelId: String(ctx.channelId), - } as Schedule); - const schedule = await this.connection - .getRepository(ctx, Schedule) - .findOneOrFail({ where: { id } }); - - return cloneSchedule(ctx, schedule); - } - - async delete(ctx: RequestContext, scheduleId: string): Promise { - const { id } = await this.connection.rawConnection - .getRepository(Schedule) - .findOneOrFail({ - where: { - id: scheduleId, - channelId: ctx.channelId as string, - }, - }); - await this.connection.getRepository(ctx, Schedule).delete({ id }); - } - - validate(input: UpsertStripeSubscriptionScheduleInput): void { - if ( - input.billingInterval === input.durationInterval && - input.billingCount === input.durationCount && - input.downpayment - ) { - throw new UserInputError( - `Paid up front schedules can not have downpayments. When duration and billing intervals are the same your schedule is a paid-up-front schedule.` - ); - } - if ( - input.startMoment === SubscriptionStartMoment.FixedStartdate && - !input.fixedStartDate - ) { - throw new UserInputError( - `Schedules with a fixed start date require a selected startDate` - ); - } - if ( - input.startMoment === SubscriptionStartMoment.FixedStartdate && - input.useProration - ) { - throw new UserInputError( - `Schedules with a fixed start date cannot use proration` - ); - } - } -} diff --git a/packages/vendure-plugin-stripe-subscription/src/api/strategy/default-subscription-strategy.ts b/packages/vendure-plugin-stripe-subscription/src/api/strategy/default-subscription-strategy.ts new file mode 100644 index 00000000..9c287f5d --- /dev/null +++ b/packages/vendure-plugin-stripe-subscription/src/api/strategy/default-subscription-strategy.ts @@ -0,0 +1,62 @@ +import { + RequestContext, + OrderLine, + Injector, + ProductVariant, + Order, +} from '@vendure/core'; +import { Subscription, SubscriptionStrategy } from './subscription-strategy'; + +/** + * This strategy creates a subscription based on the product variant: + * * The variant's price is the price per month + * * Start date is one month from now, because we ask the customer to pay the first month during checkout + */ +export class DefaultSubscriptionStrategy implements SubscriptionStrategy { + defineSubscription( + ctx: RequestContext, + injector: Injector, + productVariant: ProductVariant, + order: Order, + orderLineCustomFields: { [key: string]: any }, + quantity: number + ): Subscription { + return this.getSubscriptionForVariant(productVariant); + } + + isSubscription(ctx: RequestContext, variant: ProductVariant): boolean { + // This example treats all products as subscriptions + return true; + } + + previewSubscription( + ctx: RequestContext, + injector: Injector, + productVariant: ProductVariant, + customInputs: any + ): Subscription { + return this.getSubscriptionForVariant(productVariant); + } + + private getSubscriptionForVariant( + productVariant: ProductVariant + ): Subscription { + const price = productVariant.listPrice; + return { + name: `Subscription ${productVariant.name}`, + priceIncludesTax: productVariant.listPriceIncludesTax, + amountDueNow: price, + recurring: { + amount: price, + interval: 'month', + intervalCount: 1, + startDate: this.getOneMonthFromNow(), + }, + }; + } + + private getOneMonthFromNow(): Date { + const now = new Date(); + return new Date(now.getFullYear(), now.getMonth() + 1, now.getDate(), 12); + } +} diff --git a/packages/vendure-plugin-stripe-subscription/src/api/strategy/subscription-strategy.ts b/packages/vendure-plugin-stripe-subscription/src/api/strategy/subscription-strategy.ts new file mode 100644 index 00000000..3c76fd37 --- /dev/null +++ b/packages/vendure-plugin-stripe-subscription/src/api/strategy/subscription-strategy.ts @@ -0,0 +1,69 @@ +import { + ID, + Injector, + Order, + OrderLine, + ProductVariant, + RequestContext, +} from '@vendure/core'; + +/** + * Subscriptions can be created for Recurring payments or Recurring payments plus a one time payment + */ +export interface Subscription { + /** + * Name for displaying purposes + */ + name: string; + amountDueNow: number; + priceIncludesTax: boolean; + recurring: { + amount: number; + interval: 'week' | 'month' | 'year'; + intervalCount: number; + startDate: Date; + endDate?: Date; + }; +} + +export interface SubscriptionStrategy { + /** + * Determines if the given variant should be treated as a subscription, or as a regular product + */ + isSubscription(ctx: RequestContext, variant: ProductVariant): boolean; + + /** + * Define a subscription based on the given order line fields. + * Executed after payment has been added to order, + * before subscriptions are created in Stripe + */ + defineSubscription( + ctx: RequestContext, + injector: Injector, + productVariant: ProductVariant, + order: Order, + orderLineCustomFields: { [key: string]: any }, + quantity: number + ): + | Promise + | Subscription + | Promise + | Subscription[]; + + /** + * Preview subscription pricing for a given product variant, because there is no order line available during preview. + * Optional custom inputs can be passed in via the Graphql query, for example to preview the subscription with a custom start Date + * + * This is use by the storefront to display subscription prices before they are actually added to cart + */ + previewSubscription( + ctx: RequestContext, + injector: Injector, + productVariant: ProductVariant, + customInputs?: any + ): + | Promise + | Subscription + | Promise + | Subscription[]; +} diff --git a/packages/vendure-plugin-stripe-subscription/src/api/stripe-subscription-payment.entity.ts b/packages/vendure-plugin-stripe-subscription/src/api/stripe-subscription-payment.entity.ts deleted file mode 100644 index 7fc487d9..00000000 --- a/packages/vendure-plugin-stripe-subscription/src/api/stripe-subscription-payment.entity.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { DeepPartial } from '@vendure/common/lib/shared-types'; -import { VendureEntity, ID } from '@vendure/core'; -import { Column, Entity } from 'typeorm'; - -@Entity() -export class StripeSubscriptionPayment extends VendureEntity { - constructor(input?: DeepPartial) { - super(input); - } - - @Column({ nullable: true }) - invoiceId!: string; - - @Column({ nullable: true }) - collectionMethod!: string; - - @Column({ nullable: true }) - eventType!: string; - - @Column({ nullable: true }) - charge!: number; - - @Column({ nullable: true }) - currency!: string; - - @Column({ nullable: true }) - orderCode!: string; - - @Column({ nullable: true }) - channelId!: string; - - @Column({ nullable: true }) - subscriptionId!: string; -} diff --git a/packages/vendure-plugin-stripe-subscription/src/api/stripe-subscription.controller.ts b/packages/vendure-plugin-stripe-subscription/src/api/stripe-subscription.controller.ts index 7a53b1e6..d60b1326 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api/stripe-subscription.controller.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api/stripe-subscription.controller.ts @@ -1,204 +1,18 @@ import { Body, Controller, Headers, Inject, Post, Req } from '@nestjs/common'; -import { - Args, - Mutation, - Parent, - Query, - ResolveField, - Resolver, -} from '@nestjs/graphql'; -import { - Allow, - Api, - Ctx, - EntityHydrator, - ID, - Logger, - OrderService, - PaymentMethodService, - Permission, - ProductService, - ProductVariantService, - RequestContext, - UserInputError, -} from '@vendure/core'; -import { PaymentMethodQuote } from '@vendure/common/lib/generated-shop-types'; +import { Logger, OrderService } from '@vendure/core'; import { Request } from 'express'; import { loggerCtx, PLUGIN_INIT_OPTIONS } from '../constants'; import { StripeSubscriptionPluginOptions } from '../stripe-subscription.plugin'; -import { - StripeSubscriptionPaymentList, - StripeSubscriptionPaymentListOptions, - StripeSubscriptionPricing, - StripeSubscriptionPricingInput, - StripeSubscriptionSchedule, - StripeSubscriptionScheduleList, - StripeSubscriptionScheduleListOptions, - UpsertStripeSubscriptionScheduleInput, -} from '../ui/generated/graphql'; -import { ScheduleService } from './schedule.service'; import { StripeSubscriptionService } from './stripe-subscription.service'; -import { - OrderLineWithSubscriptionFields, - VariantWithSubscriptionFields, -} from './subscription-custom-fields'; import { StripeInvoice } from './types/stripe-invoice'; -import { StripePaymentIntent } from './types/stripe-payment-intent'; -import { IncomingStripeWebhook } from './types/stripe.types'; -import { ApiType } from '@vendure/core/dist/api/common/get-api-type'; +import { + StripePaymentIntent, + StripeSetupIntent, +} from './types/stripe-payment-intent'; +import { IncomingStripeWebhook } from './types/stripe.common'; export type RequestWithRawBody = Request & { rawBody: any }; -@Resolver() -export class ShopResolver { - constructor( - private stripeSubscriptionService: StripeSubscriptionService, - private orderService: OrderService, - private productService: ProductService, - private paymentMethodService: PaymentMethodService - ) {} - - @Mutation() - @Allow(Permission.Owner) - async createStripeSubscriptionIntent( - @Ctx() ctx: RequestContext - ): Promise { - return this.stripeSubscriptionService.createPaymentIntent(ctx); - } - - @Query() - async stripeSubscriptionPricing( - @Ctx() ctx: RequestContext, - @Args('input') input: StripeSubscriptionPricingInput - ): Promise { - return this.stripeSubscriptionService.getPricingForVariant(ctx, input); - } - - @Query() - async stripeSubscriptionPricingForProduct( - @Ctx() ctx: RequestContext, - @Args('productId') productId: ID - ): Promise { - const product = await this.productService.findOne(ctx, productId, [ - 'variants', - ]); - if (!product) { - throw new UserInputError(`No product with id '${productId}' found`); - } - const subscriptionVariants = product.variants.filter( - (v: VariantWithSubscriptionFields) => - !!v.customFields.subscriptionSchedule && v.enabled - ); - return await Promise.all( - subscriptionVariants.map((variant) => - this.stripeSubscriptionService.getPricingForVariant(ctx, { - productVariantId: variant.id as string, - }) - ) - ); - } - - @ResolveField('stripeSubscriptionPublishableKey') - @Resolver('PaymentMethodQuote') - async stripeSubscriptionPublishableKey( - @Ctx() ctx: RequestContext, - @Parent() paymentMethodQuote: PaymentMethodQuote - ): Promise { - const paymentMethod = await this.paymentMethodService.findOne( - ctx, - paymentMethodQuote.id - ); - if (!paymentMethod) { - throw new UserInputError( - `No payment method with id '${paymentMethodQuote.id}' found. Unable to resolve field"stripeSubscriptionPublishableKey"` - ); - } - return paymentMethod.handler.args.find((a) => a.name === 'publishableKey') - ?.value; - } -} - -@Resolver('OrderLine') -export class OrderLinePricingResolver { - constructor( - private entityHydrator: EntityHydrator, - private subscriptionService: StripeSubscriptionService - ) {} - - @ResolveField() - async subscriptionPricing( - @Ctx() ctx: RequestContext, - @Parent() orderLine: OrderLineWithSubscriptionFields - ): Promise { - await this.entityHydrator.hydrate(ctx, orderLine, { - relations: ['productVariant'], - }); - if (orderLine.productVariant?.customFields?.subscriptionSchedule) { - return await this.subscriptionService.getPricingForOrderLine( - ctx, - orderLine - ); - } - return; - } -} - -// This is needed to resolve schedule.pricesIncludeTax in the Admin UI -@Resolver('StripeSubscriptionSchedule') -export class AdminPriceIncludesTaxResolver { - @ResolveField() - pricesIncludeTax( - @Ctx() ctx: RequestContext, - @Parent() orderLine: OrderLineWithSubscriptionFields - ): boolean { - return ctx.channel.pricesIncludeTax; - } -} - -@Resolver() -export class AdminResolver { - constructor( - private stripeSubscriptionService: StripeSubscriptionService, - private scheduleService: ScheduleService - ) {} - - @Allow(Permission.ReadSettings) - @Query() - async stripeSubscriptionSchedules( - @Ctx() ctx: RequestContext, - @Args('options') options: StripeSubscriptionScheduleListOptions - ): Promise { - return this.scheduleService.getSchedules(ctx, options); - } - - @Allow(Permission.ReadSettings) - @Query() - async stripeSubscriptionPayments( - @Ctx() ctx: RequestContext, - @Args('options') options: StripeSubscriptionPaymentListOptions - ): Promise { - return this.stripeSubscriptionService.getPaymentEvents(ctx, options); - } - - @Allow(Permission.UpdateSettings) - @Mutation() - async upsertStripeSubscriptionSchedule( - @Ctx() ctx: RequestContext, - @Args('input') input: UpsertStripeSubscriptionScheduleInput - ): Promise { - return this.scheduleService.upsert(ctx, input); - } - - @Allow(Permission.UpdateSettings) - @Mutation() - async deleteStripeSubscriptionSchedule( - @Ctx() ctx: RequestContext, - @Args('scheduleId') scheduleId: string - ): Promise { - return this.scheduleService.delete(ctx, scheduleId); - } -} - @Controller('stripe-subscriptions') export class StripeSubscriptionController { constructor( @@ -222,26 +36,17 @@ export class StripeSubscriptionController { const channelToken = body.data.object.metadata?.channelToken ?? (body.data.object as StripeInvoice).lines?.data[0]?.metadata.channelToken; - if ( - body.type !== 'payment_intent.succeeded' && - body.type !== 'invoice.payment_failed' && - body.type !== 'invoice.payment_succeeded' - ) { + if (!StripeSubscriptionService.webhookEvents.includes(body.type as any)) { Logger.info( `Received incoming '${body.type}' webhook, not processing this event.`, loggerCtx ); return; } - if (!orderCode) { - return Logger.error( - `Incoming webhook is missing metadata.orderCode, cannot process this event`, - loggerCtx - ); - } - if (!channelToken) { - return Logger.error( - `Incoming webhook is missing metadata.channelToken, cannot process this event`, + if (!orderCode || !channelToken) { + // For some reasone we get a webhook without metadata first, we ignore it + return Logger.info( + `Incoming webhook is missing metadata.orderCode/channelToken, ignoring. We should receive another one with metadata...`, loggerCtx ); } @@ -260,48 +65,25 @@ export class StripeSubscriptionController { if (!this.options?.disableWebhookSignatureChecking) { stripeClient.validateWebhookSignature(request.rawBody, signature); } - if (body.type === 'payment_intent.succeeded') { - await this.stripeSubscriptionService.handlePaymentIntentSucceeded( + if ( + body.type === 'payment_intent.succeeded' || + body.type === 'setup_intent.succeeded' + ) { + await this.stripeSubscriptionService.handleIntentSucceeded( ctx, - body.data.object as StripePaymentIntent, + body.data.object as StripePaymentIntent & StripeSetupIntent, order ); - } else if (body.type === 'invoice.payment_succeeded') { - const invoiceObject = body.data.object as StripeInvoice; - await this.stripeSubscriptionService.handleInvoicePaymentSucceeded( - ctx, - invoiceObject, - order - ); - await this.stripeSubscriptionService.savePaymentEvent( - ctx, - body.type, - invoiceObject - ); - } else if (body.type === 'invoice.payment_failed') { - const invoiceObject = body.data.object as StripeInvoice; - await this.stripeSubscriptionService.handleInvoicePaymentFailed( - ctx, - invoiceObject, - order - ); - await this.stripeSubscriptionService.savePaymentEvent( - ctx, - body.type, - invoiceObject - ); - } else if (body.type === 'invoice.payment_action_required') { + } else if ( + body.type === 'invoice.payment_failed' || + body.type === 'invoice.payment_action_required' + ) { const invoiceObject = body.data.object as StripeInvoice; await this.stripeSubscriptionService.handleInvoicePaymentFailed( ctx, invoiceObject, order ); - await this.stripeSubscriptionService.savePaymentEvent( - ctx, - body.type, - invoiceObject - ); } Logger.info(`Successfully handled webhook ${body.type}`, loggerCtx); } catch (error) { diff --git a/packages/vendure-plugin-stripe-subscription/src/api/stripe-subscription.resolver.ts b/packages/vendure-plugin-stripe-subscription/src/api/stripe-subscription.resolver.ts new file mode 100644 index 00000000..376e1d8a --- /dev/null +++ b/packages/vendure-plugin-stripe-subscription/src/api/stripe-subscription.resolver.ts @@ -0,0 +1,89 @@ +import { + Args, + Mutation, + Parent, + Query, + ResolveField, + Resolver, +} from '@nestjs/graphql'; +import { PaymentMethodQuote } from '@vendure/common/lib/generated-shop-types'; +import { + Allow, + Ctx, + PaymentMethodService, + Permission, + RequestContext, + UserInputError, +} from '@vendure/core'; +import { Request } from 'express'; +import { + Mutation as GraphqlMutation, + Query as GraphqlQuery, + QueryPreviewStripeSubscriptionsArgs, + QueryPreviewStripeSubscriptionsForProductArgs, +} from './generated/graphql'; +import { StripeSubscriptionService } from './stripe-subscription.service'; + +export type RequestWithRawBody = Request & { rawBody: any }; + +@Resolver() +export class StripeSubscriptionShopResolver { + constructor( + private stripeSubscriptionService: StripeSubscriptionService, + private paymentMethodService: PaymentMethodService + ) {} + + @Mutation() + @Allow(Permission.Owner) + async createStripeSubscriptionIntent( + @Ctx() ctx: RequestContext + ): Promise { + const res = await this.stripeSubscriptionService.createIntent(ctx); + return res; + } + + @Query() + async previewStripeSubscriptions( + @Ctx() ctx: RequestContext, + @Args() + { productVariantId, customInputs }: QueryPreviewStripeSubscriptionsArgs + ): Promise { + return this.stripeSubscriptionService.previewSubscription( + ctx, + productVariantId, + customInputs + ); + } + + @Query() + async previewStripeSubscriptionsForProduct( + @Ctx() ctx: RequestContext, + @Args() + { productId, customInputs }: QueryPreviewStripeSubscriptionsForProductArgs + ): Promise { + return this.stripeSubscriptionService.previewSubscriptionForProduct( + ctx, + productId, + customInputs + ); + } + + @ResolveField('stripeSubscriptionPublishableKey') + @Resolver('PaymentMethodQuote') + async stripeSubscriptionPublishableKey( + @Ctx() ctx: RequestContext, + @Parent() paymentMethodQuote: PaymentMethodQuote + ): Promise { + const paymentMethod = await this.paymentMethodService.findOne( + ctx, + paymentMethodQuote.id + ); + if (!paymentMethod) { + throw new UserInputError( + `No payment method with id '${paymentMethodQuote.id}' found. Unable to resolve field"stripeSubscriptionPublishableKey"` + ); + } + return paymentMethod.handler.args.find((a) => a.name === 'publishableKey') + ?.value; + } +} diff --git a/packages/vendure-plugin-stripe-subscription/src/api/stripe-subscription.service.ts b/packages/vendure-plugin-stripe-subscription/src/api/stripe-subscription.service.ts index 3df5fb09..f014a791 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api/stripe-subscription.service.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api/stripe-subscription.service.ts @@ -1,30 +1,28 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; import { StockMovementType } from '@vendure/common/lib/generated-types'; import { ActiveOrderService, - Channel, ChannelService, - CustomerService, EntityHydrator, ErrorResult, EventBus, HistoryService, ID, - InternalServerError, + Injector, JobQueue, JobQueueService, LanguageCode, - ListQueryBuilder, - ListQueryOptions, Logger, Order, OrderLine, OrderLineEvent, OrderService, OrderStateTransitionError, - PaginatedList, PaymentMethod, + PaymentMethodEvent, PaymentMethodService, + ProductService, ProductVariantService, RequestContext, SerializedRequestContext, @@ -32,38 +30,31 @@ import { TransactionalConnection, UserInputError, } from '@vendure/core'; -import { loggerCtx } from '../constants'; -import { IncomingStripeWebhook } from './types/stripe.types'; -import { - CustomerWithSubscriptionFields, - OrderLineWithSubscriptionFields, - OrderWithSubscriptionFields, - VariantWithSubscriptionFields, -} from './subscription-custom-fields'; -import { StripeClient } from './stripe.client'; -import { - StripeSubscriptionPaymentList, - StripeSubscriptionPaymentListOptions, - StripeSubscriptionPricing, - StripeSubscriptionPricingInput, -} from '../ui/generated/graphql'; -import { stripeSubscriptionHandler } from './stripe-subscription.handler'; -import { Request } from 'express'; -import { filter } from 'rxjs/operators'; -import { - calculateSubscriptionPricing, - applySubscriptionPromotions, - getNextCyclesStartDate, - printMoney, -} from './pricing.helper'; import { Cancellation } from '@vendure/core/dist/entity/stock-movement/cancellation.entity'; import { Release } from '@vendure/core/dist/entity/stock-movement/release.entity'; import { randomUUID } from 'crypto'; -import { hasSubscriptions } from './has-stripe-subscription-products-payment-checker'; -import { StripeSubscriptionPayment } from './stripe-subscription-payment.entity'; -import { StripeInvoice } from './types/stripe-invoice'; -import { StripePaymentIntent } from './types/stripe-payment-intent'; +import { sub } from 'date-fns'; +import { Request } from 'express'; +import { filter } from 'rxjs/operators'; import Stripe from 'stripe'; +import { loggerCtx, PLUGIN_INIT_OPTIONS } from '../constants'; +import { StripeSubscriptionPluginOptions } from '../stripe-subscription.plugin'; +import { + StripeSubscription, + StripeSubscriptionIntent, +} from './generated/graphql'; +import { + Subscription, + SubscriptionStrategy, +} from './strategy/subscription-strategy'; +import { StripeClient } from './stripe.client'; +import { StripeInvoice } from './types/stripe-invoice'; +import { + StripePaymentIntent, + StripeSetupIntent, +} from './types/stripe-payment-intent'; +import { printMoney } from './util'; +import { stripeSubscriptionHandler } from './vendure-config/stripe-subscription.handler'; export interface StripeContext { paymentMethod: PaymentMethod; @@ -91,19 +82,34 @@ export class StripeSubscriptionService { constructor( private paymentMethodService: PaymentMethodService, private activeOrderService: ActiveOrderService, - private variantService: ProductVariantService, private entityHydrator: EntityHydrator, private channelService: ChannelService, private orderService: OrderService, - private listQueryBuilder: ListQueryBuilder, private historyService: HistoryService, private eventBus: EventBus, private jobQueueService: JobQueueService, - private customerService: CustomerService, - private connection: TransactionalConnection - ) {} + private moduleRef: ModuleRef, + private connection: TransactionalConnection, + private productVariantService: ProductVariantService, + private productService: ProductService, + @Inject(PLUGIN_INIT_OPTIONS) + private options: StripeSubscriptionPluginOptions + ) { + this.strategy = this.options.subscriptionStrategy!; + } private jobQueue!: JobQueue; + readonly strategy: SubscriptionStrategy; + /** + * The plugin expects these events to come in via webhooks + */ + static webhookEvents: Stripe.WebhookEndpointCreateParams.EnabledEvent[] = [ + 'payment_intent.succeeded', + 'setup_intent.succeeded', + 'invoice.payment_failed', + 'invoice.payment_succeeded', + 'invoice.payment_action_required', + ]; async onModuleInit() { // Create jobQueue with handlers @@ -146,10 +152,9 @@ export class StripeSubscriptionService { }); // Add unique hash for subscriptions, so Vendure creates a new order line this.eventBus.ofType(OrderLineEvent).subscribe(async (event) => { - const orderLine = event.orderLine as OrderLineWithSubscriptionFields; if ( event.type === 'created' && - orderLine.productVariant.customFields.subscriptionSchedule + this.strategy.isSubscription(event.ctx, event.orderLine.productVariant) ) { await this.connection .getRepository(event.ctx, OrderLine) @@ -159,11 +164,10 @@ export class StripeSubscriptionService { ); } }); - // Listen for stock cancellation or release events + // Listen for stock cancellation or release events, to cancel an order lines subscription this.eventBus .ofType(StockMovementEvent) .pipe( - // Filter by event type filter( (event) => event.type === StockMovementType.RELEASE || @@ -171,41 +175,172 @@ export class StripeSubscriptionService { ) ) .subscribe(async (event) => { - const orderLinesWithSubscriptions = ( - event.stockMovements as (Cancellation | Release)[] - ) - .map( - (stockMovement) => - stockMovement.orderLine as OrderLineWithSubscriptionFields - ) + const cancelOrReleaseEvents = event.stockMovements as ( + | Cancellation + | Release + )[]; + const stockEvents = cancelOrReleaseEvents // Filter out non-sub orderlines - .filter((orderLine) => orderLine.customFields.subscriptionIds); + .filter( + (event) => (event.orderLine.customFields as any).subscriptionIds + ); await Promise.all( // Push jobs - orderLinesWithSubscriptions.map((line) => + stockEvents.map((stockEvent) => this.jobQueue.add({ ctx: event.ctx.serialize(), action: 'cancelSubscriptionsForOrderline', - orderLineId: line.id, + orderLineId: stockEvent.orderLine.id, }) ) ); }); + // Listen for PaymentMethod create or update, to automatically create webhooks + this.eventBus.ofType(PaymentMethodEvent).subscribe(async (event) => { + if (event.type === 'created' || event.type === 'updated') { + const paymentMethod = event.entity; + if (paymentMethod.handler.code === stripeSubscriptionHandler.code) { + await this.registerWebhooks(event.ctx, paymentMethod).catch((e) => { + Logger.error( + `Failed to register webhooks for channel ${event.ctx.channel.token}: ${e}`, + loggerCtx + ); + }); + } + } + }); + } + + /** + * Register webhook with the right events if they don't exist yet. + * If already exists, the existing hook is deleted and a new one is created. + * Existence is checked by name. + * + * Saves the webhook secret irectly on the payment method + */ + async registerWebhooks( + ctx: RequestContext, + paymentMethod: PaymentMethod + ): Promise | undefined> { + const webhookDescription = `Vendure Stripe Subscription Webhook for channel ${ctx.channel.token}`; + const apiKey = paymentMethod.handler.args.find( + (arg) => arg.name === 'apiKey' + )?.value; + if (!apiKey) { + throw new UserInputError( + `No api key found for payment method ${paymentMethod.code}, can not register webhooks` + ); + } + const stripeClient = new StripeClient('not-yet-available-secret', apiKey, { + apiVersion: null as any, // Null uses accounts default version + }); + const webhookUrl = `${this.options.vendureHost}/stripe-subscriptions/webhook`; + // Get existing webhooks and check if url and events match. If not, create them + const webhooks = await stripeClient.webhookEndpoints.list({ limit: 100 }); + if (webhooks.data.length === 100) { + Logger.error( + `Your Stripe account has too many webhooks setup, ` + + `you will need to manually create the webhook with events ${StripeSubscriptionService.webhookEvents.join( + ', ' + )}`, + loggerCtx + ); + return; + } + const existingWebhook = webhooks.data.find( + (w) => w.description === webhookDescription + ); + if (existingWebhook) { + await stripeClient.webhookEndpoints.del(existingWebhook.id); + } + const createdHook = await stripeClient.webhookEndpoints.create({ + enabled_events: StripeSubscriptionService.webhookEvents, + description: webhookDescription, + url: webhookUrl, + }); + // Update webhook secret in paymentMethod + paymentMethod.handler.args.forEach((arg) => { + if (arg.name === 'webhookSecret') { + arg.value = createdHook.secret!; + } + }); + const res = await this.connection + .getRepository(ctx, PaymentMethod) + .save(paymentMethod); + Logger.info( + `Created webhook ${createdHook.id} for channel ${ctx.channel.token}`, + loggerCtx + ); + return createdHook; + } + + async previewSubscription( + ctx: RequestContext, + productVariantId: ID, + customInputs?: any + ): Promise { + const variant = await this.productVariantService.findOne( + ctx, + productVariantId + ); + if (!variant) { + throw new UserInputError( + `No product variant with id '${productVariantId}' found` + ); + } + const injector = new Injector(this.moduleRef); + const subscriptions = await this.strategy.previewSubscription( + ctx, + injector, + variant, + customInputs + ); + if (Array.isArray(subscriptions)) { + return subscriptions.map((sub) => ({ + ...sub, + variantId: variant.id, + })); + } else { + return [ + { + ...subscriptions, + variantId: variant.id, + }, + ]; + } + } + + async previewSubscriptionForProduct( + ctx: RequestContext, + productId: ID, + customInputs?: any + ): Promise { + const { items: variants } = + await this.productVariantService.getVariantsByProductId(ctx, productId); + if (!variants?.length) { + throw new UserInputError(`No variants for product '${productId}' found`); + } + const subscriptions = await Promise.all( + variants.map((v) => this.previewSubscription(ctx, v.id, customInputs)) + ); + return subscriptions.flat(); } async cancelSubscriptionForOrderLine( ctx: RequestContext, orderLineId: ID ): Promise { - const order = (await this.orderService.findOneByOrderLineId( + const order = await this.orderService.findOneByOrderLineId( ctx, orderLineId, ['lines'] - )) as OrderWithSubscriptionFields | undefined; + ); if (!order) { throw Error(`Order for OrderLine ${orderLineId} not found`); } - const line = order?.lines.find((l) => l.id == orderLineId); + const line = order?.lines.find((l) => l.id == orderLineId) as + | any + | undefined; if (!line?.customFields.subscriptionIds?.length) { return Logger.info( `OrderLine ${orderLineId} of ${orderLineId} has no subscriptionIds. Not cancelling anything... `, @@ -270,36 +405,26 @@ export class StripeSubscriptionService { return stripeClient.subscriptions.retrieve(subscriptionId); } - async createPaymentIntent(ctx: RequestContext): Promise { - let order = (await this.activeOrderService.getActiveOrder( - ctx, - undefined - )) as OrderWithSubscriptionFields; + async createIntent(ctx: RequestContext): Promise { + let order = await this.activeOrderService.getActiveOrder(ctx, undefined); if (!order) { throw new UserInputError('No active order for session'); } - if (!order.totalWithTax) { - // Add a verification fee to the order to support orders that are actually $0 - order = (await this.orderService.addSurchargeToOrder(ctx, order.id, { - description: 'Verification fee', - listPrice: 100, - listPriceIncludesTax: true, - })) as OrderWithSubscriptionFields; - } await this.entityHydrator.hydrate(ctx, order, { relations: ['customer', 'shippingLines', 'lines.productVariant'], + applyProductVariantPrices: true, }); if (!order.lines?.length) { - throw new UserInputError('Cannot create payment intent for empty order'); + throw new UserInputError('Cannot create intent for empty order'); } if (!order.customer) { throw new UserInputError( - 'Cannot create payment intent for order without customer' + 'Cannot create intent for order without customer' ); } if (!order.shippingLines?.length) { throw new UserInputError( - 'Cannot create payment intent for order without shippingMethod' + 'Cannot create intent for order without shippingMethod' ); } // Check if Stripe Subscription paymentMethod is eligible for this order @@ -311,198 +436,122 @@ export class StripeSubscriptionService { const { stripeClient, paymentMethod } = await this.getStripeContext(ctx); if (!eligibleStripeMethodCodes.includes(paymentMethod.code)) { throw new UserInputError( - `No eligible payment method found with code \'stripe-subscription\'` + `No eligible payment method found for order ${order.code} with handler code '${stripeSubscriptionHandler.code}'` ); } + await this.orderService.transitionToState( + ctx, + order.id, + 'ArrangingPayment' + ); const stripeCustomer = await stripeClient.getOrCreateCustomer( order.customer ); - this.customerService - .update(ctx, { - id: order.customer.id, - customFields: { - stripeSubscriptionCustomerId: stripeCustomer.id, - }, - }) - .catch((err) => - Logger.error( - `Failed to update stripeCustomerId ${stripeCustomer.id} for ${order.customer.emailAddress}`, - loggerCtx, - err - ) - ); - const hasSubscriptionProducts = order.lines.some( - (l) => l.productVariant.customFields.subscriptionSchedule - ); - const intent = await stripeClient.paymentIntents.create({ - customer: stripeCustomer.id, - payment_method_types: ['card'], // TODO make configurable per channel - setup_future_usage: hasSubscriptionProducts - ? 'off_session' - : 'on_session', - amount: order.totalWithTax, - currency: order.currencyCode, - metadata: { - orderCode: order.code, - channelToken: ctx.channel.token, + const stripePaymentMethods = ['card']; // TODO make configurable per channel + let intent: Stripe.PaymentIntent | Stripe.SetupIntent; + if (order.totalWithTax > 0) { + // Create PaymentIntent + off_session, because we have both one-time and recurring payments. Order total is only > 0 if there are one-time payments + intent = await stripeClient.paymentIntents.create({ + customer: stripeCustomer.id, + payment_method_types: stripePaymentMethods, + setup_future_usage: 'off_session', amount: order.totalWithTax, - }, - }); - Logger.info( - `Created payment intent '${intent.id}' for order ${order.code}`, - loggerCtx - ); - return intent.client_secret!; - } - - /** - * Used for previewing the prices including VAT of a subscription - */ - async getPricingForVariant( - ctx: RequestContext, - input: StripeSubscriptionPricingInput - ): Promise { - const variant = (await this.variantService.findOne( - ctx, - input.productVariantId! - )) as VariantWithSubscriptionFields; - if (!variant || !variant?.enabled) { - throw new UserInputError( - `No variant found with id ${input!.productVariantId}` - ); - } - if (!variant.listPrice) { - throw new UserInputError( - `Variant ${variant.id} doesn't have a "listPrice". Variant.listPrice is needed to calculate subscription pricing` - ); + currency: order.currencyCode, + metadata: { + orderCode: order.code, + channelToken: ctx.channel.token, + amount: order.totalWithTax, + }, + }); + } else { + // Create SetupIntent, because we only have recurring payments + intent = await stripeClient.setupIntents.create({ + customer: stripeCustomer.id, + payment_method_types: stripePaymentMethods, + usage: 'off_session', + metadata: { + orderCode: order.code, + channelToken: ctx.channel.token, + amount: order.totalWithTax, + }, + }); } - if (!variant.customFields.subscriptionSchedule) { - throw new UserInputError( - `Variant ${variant.id} doesn't have a schedule attached` + const intentType = + intent.object === 'payment_intent' ? 'PaymentIntent' : 'SetupIntent'; + if (!intent.client_secret) { + throw Error( + `No client_secret found in ${intentType} response, something went wrong!` ); } - const subscriptionPricing = calculateSubscriptionPricing( - ctx, - variant.listPrice, - variant.customFields.subscriptionSchedule, - input + Logger.info( + `Created ${intentType} '${intent.id}' for order ${order.code}`, + loggerCtx ); return { - ...subscriptionPricing, - variantId: variant.id as string, - // original price is the same as the recurring price without discounts - originalRecurringPrice: subscriptionPricing.recurringPrice, + clientSecret: intent.client_secret, + intentType, }; } /** - * - * Calculate subscription pricing based on an orderLine. - * This differs from a variant, because orderLines can have discounts applied + * This defines the actual subscriptions and prices for each order line, based on the configured strategy. + * Doesn't allow recurring amount to be below 0 or lower */ - async getPricingForOrderLine( - ctx: RequestContext, - orderLine: OrderLineWithSubscriptionFields - ): Promise { - await this.entityHydrator.hydrate(ctx, orderLine, { - relations: ['productVariant.taxCategory', 'order', 'order.promotions'], - applyProductVariantPrices: true, - }); - if (!orderLine.productVariant?.enabled) { - throw new UserInputError( - `Variant ${orderLine.productVariant.sku} is not enabled` - ); - } - if (!orderLine.productVariant.customFields.subscriptionSchedule) { - throw new UserInputError( - `Variant ${orderLine.productVariant.id} doesn't have a schedule attached` - ); - } - const subscriptionPricing = calculateSubscriptionPricing( - ctx, - orderLine.productVariant.listPrice, - orderLine.productVariant.customFields.subscriptionSchedule, - { - downpayment: orderLine.customFields.downpayment, - startDate: orderLine.customFields.startDate, - } - ); - // Execute promotions on recurringPrice - const discountedRecurringPrice = await applySubscriptionPromotions( - ctx, - subscriptionPricing.recurringPrice, - orderLine, - orderLine.order.promotions || [] - ); - return { - ...subscriptionPricing, - variantId: orderLine.productVariant.id as string, - originalRecurringPrice: subscriptionPricing.recurringPrice, - recurringPrice: Math.round(discountedRecurringPrice), - }; - } - - async savePaymentEvent( + async defineSubscriptions( ctx: RequestContext, - eventType: string, - object: StripeInvoice - ): Promise { - const stripeSubscriptionPaymentRepo = this.connection.getRepository( - ctx, - StripeSubscriptionPayment + order: Order + ): Promise<(Subscription & { orderLineId: ID; variantId: ID })[]> { + const injector = new Injector(this.moduleRef); + // Only define subscriptions for orderlines with a subscription product variant + const subscriptionOrderLines = order.lines.filter((l) => + this.strategy.isSubscription(ctx, l.productVariant) ); - const charge = object.lines.data.reduce( - (acc, line) => acc + (line.plan?.amount ?? 0), - 0 + const subscriptions = await Promise.all( + subscriptionOrderLines.map(async (line) => { + const subs = await this.strategy.defineSubscription( + ctx, + injector, + line.productVariant, + order, + line.customFields, + line.quantity + ); + // Add orderlineId to subscription + if (Array.isArray(subs)) { + return subs.map((sub) => ({ + orderLineId: line.id, + variantId: line.productVariant.id, + ...sub, + })); + } + return { + orderLineId: line.id, + variantId: line.productVariant.id, + ...subs, + }; + }) ); - const newPayment = new StripeSubscriptionPayment({ - channelId: ctx.channel.id as string, - eventType, - charge: charge, - currency: object.currency ?? ctx.channel.defaultCurrencyCode, - collectionMethod: object.collection_method, - invoiceId: object.id, - orderCode: - object.metadata?.orderCode ?? - object.lines?.data[0]?.metadata.orderCode ?? - '', - subscriptionId: object.subscription, + const flattenedSubscriptionsArray = subscriptions.flat(); + // Validate recurring amount + flattenedSubscriptionsArray.forEach((subscription) => { + if ( + !subscription.recurring.amount || + subscription.recurring.amount <= 0 + ) { + throw Error( + `[${loggerCtx}]: Defined subscription for order line ${subscription.variantId} must have a recurring amount greater than 0` + ); + } }); - await stripeSubscriptionPaymentRepo.save(newPayment); - } - - async getPaymentEvents( - ctx: RequestContext, - options: StripeSubscriptionPaymentListOptions - ): Promise { - return this.listQueryBuilder - .build(StripeSubscriptionPayment, options, { ctx }) - .getManyAndCount() - .then(([items, totalItems]) => ({ - items, - totalItems, - })); + return flattenedSubscriptionsArray; } /** - * Handle future subscription payments that come in after the initial payment intent + * Check if the order has products that should be treated as subscription products */ - async handleInvoicePaymentSucceeded( - ctx: RequestContext, - object: StripeInvoice, - order: Order - ): Promise { - const amount = object.lines?.data?.[0]?.plan?.amount; - const message = amount - ? `Received subscription payment of ${printMoney(amount)}` - : 'Received subscription payment'; - await this.logHistoryEntry( - ctx, - order.id, - message, - undefined, - undefined, - object.subscription + hasSubscriptionProducts(ctx: RequestContext, order: Order): boolean { + return order.lines.some((l) => + this.strategy.isSubscription(ctx, l.productVariant) ); } @@ -514,6 +563,7 @@ export class StripeSubscriptionService { object: StripeInvoice, order: Order ): Promise { + // TODO: Emit StripeSubscriptionPaymentFailed(subscriptionId, order, stripeInvoiceObject: StripeInvoice) const amount = object.lines?.data[0]?.plan?.amount; const message = amount ? `Subscription payment of ${printMoney(amount)} failed` @@ -529,12 +579,12 @@ export class StripeSubscriptionService { } /** - * Handle the initial payment Intent succeeded. - * Creates subscriptions in Stripe for customer attached to this order + * Handle the initial succeeded setup or payment intent. + * Creates subscriptions in Stripe in the background via the jobqueue */ - async handlePaymentIntentSucceeded( + async handleIntentSucceeded( ctx: RequestContext, - object: StripePaymentIntent, + object: StripePaymentIntent | StripeSetupIntent, order: Order ): Promise { const { @@ -559,7 +609,7 @@ export class StripeSubscriptionService { stripePaymentMethodId: object.payment_method, stripeCustomerId: object.customer, }, - { retries: 0 } // Only 1 try, because subscription creation isn't transaction-proof + { retries: 0 } // Only 1 try, because subscription creation isn't idempotent ) .catch((e) => Logger.error( @@ -567,7 +617,7 @@ export class StripeSubscriptionService { loggerCtx ) ); - // Status is complete, we can settle payment + // Settle payment for order if (order.state !== 'ArrangingPayment') { const transitionToStateResult = await this.orderService.transitionToState( ctx, @@ -593,13 +643,17 @@ export class StripeSubscriptionService { ); if ((addPaymentToOrderResult as ErrorResult).errorCode) { throw Error( - `Error adding payment to order ${order.code}: ${ + `[${loggerCtx}]: Error adding payment to order ${order.code}: ${ (addPaymentToOrderResult as ErrorResult).message }` ); } Logger.info( - `Successfully settled payment for order ${order.code} for channel ${ctx.channel.token}`, + `Successfully settled payment for order ${ + order.code + } with amount ${printMoney(object.metadata.amount)}, for channel ${ + ctx.channel.token + }`, loggerCtx ); } @@ -613,181 +667,129 @@ export class StripeSubscriptionService { stripeCustomerId: string, stripePaymentMethodId: string ): Promise { - const order = (await this.orderService.findOneByCode(ctx, orderCode, [ + const order = await this.orderService.findOneByCode(ctx, orderCode, [ 'customer', 'lines', 'lines.productVariant', - ])) as OrderWithSubscriptionFields; + ]); if (!order) { - throw Error(`Cannot find order with code ${orderCode}`); - } - if (!hasSubscriptions(order)) { - return Logger.info( - `Not creating subscriptions for order ${order.code}, because it doesn't have any subscription products` - ); - } - const { stripeClient } = await this.getStripeContext(ctx); - const customer = await stripeClient.customers.retrieve(stripeCustomerId); - if (!customer) { - throw Error( - `Failed to create subscription for ${stripeCustomerId} because the customer doesn't exist in Stripe` - ); + throw Error(`[${loggerCtx}]: Cannot find order with code ${orderCode}`); } - let orderLineCount = 0; - const subscriptionOrderLines = order.lines.filter( - (line) => line.productVariant.customFields.subscriptionSchedule - ); - for (const orderLine of subscriptionOrderLines) { - orderLineCount++; // Start with 1 - const createdSubscriptions: string[] = []; - const pricing = await this.getPricingForOrderLine(ctx, orderLine); - if (pricing.schedule.paidUpFront && !pricing.schedule.autoRenew) { - continue; // Paid up front without autoRenew doesn't need a subscription + try { + if (!this.hasSubscriptionProducts(ctx, order)) { + Logger.info( + `Order ${order.code} doesn't have any subscriptions. No action needed`, + loggerCtx + ); + return; + } + const { stripeClient } = await this.getStripeContext(ctx); + const customer = await stripeClient.customers.retrieve(stripeCustomerId); + if (!customer) { + throw Error( + `[${loggerCtx}]: Failed to create subscription for customer ${stripeCustomerId} because it doesn't exist in Stripe` + ); } + const subscriptionDefinitions = await this.defineSubscriptions( + ctx, + order + ); Logger.info(`Creating subscriptions for ${orderCode}`, loggerCtx); - try { - const product = await stripeClient.products.create({ - name: `${orderLine.productVariant.name} (${order.code})`, - }); - const recurringSubscription = - await stripeClient.createOffSessionSubscription({ - customerId: stripeCustomerId, - productId: product.id, - currencyCode: order.currencyCode, - amount: pricing.recurringPrice, - interval: pricing.interval, - intervalCount: pricing.intervalCount, - paymentMethodId: stripePaymentMethodId, - startDate: pricing.subscriptionStartDate, - endDate: pricing.subscriptionEndDate || undefined, - description: orderLine.productVariant.name, - orderCode: order.code, - channelToken: ctx.channel.token, - }); - createdSubscriptions.push(recurringSubscription.id); - if ( - recurringSubscription.status !== 'active' && - recurringSubscription.status !== 'trialing' - ) { - Logger.error( - `Failed to create active subscription ${recurringSubscription.id} for order ${order.code}! It is still in status '${recurringSubscription.status}'`, - loggerCtx - ); - await this.logHistoryEntry( - ctx, - order.id, - 'Failed to create subscription', - `Subscription status is ${recurringSubscription.status}`, - pricing, - recurringSubscription.id - ); - } else { - Logger.info( - `Created subscription ${recurringSubscription.id}: ${printMoney( - pricing.recurringPrice - )} every ${pricing.intervalCount} ${ - pricing.interval - }(s) with startDate ${pricing.subscriptionStartDate} for order ${ - order.code - }`, - loggerCtx - ); - await this.logHistoryEntry( - ctx, - order.id, - `Created subscription for line ${orderLineCount}`, - undefined, - pricing, - recurringSubscription.id - ); - } - if (pricing.downpayment) { - // Create downpayment with the interval of the duration. So, if the subscription renews in 6 months, then the downpayment should occur every 6 months - const downpaymentProduct = await stripeClient.products.create({ - name: `${orderLine.productVariant.name} - Downpayment (${order.code})`, + // + const subscriptionsPerOrderLine = new Map(); + for (const subscriptionDefinition of subscriptionDefinitions) { + try { + const product = await stripeClient.products.create({ + name: subscriptionDefinition.name, }); - const schedule = - orderLine.productVariant.customFields.subscriptionSchedule; - if (!schedule) { - throw new UserInputError( - `Variant ${orderLine.productVariant.id} doesn't have a schedule attached` - ); - } - const downpaymentInterval = schedule.durationInterval; - const downpaymentIntervalCount = schedule.durationCount; - const nextDownpaymentDate = getNextCyclesStartDate( - new Date(), - schedule.startMoment, - schedule.durationInterval, - schedule.durationCount, - schedule.fixedStartDate - ); - const downpaymentSubscription = + const createdSubscription = await stripeClient.createOffSessionSubscription({ customerId: stripeCustomerId, - productId: downpaymentProduct.id, + productId: product.id, currencyCode: order.currencyCode, - amount: pricing.downpayment, - interval: downpaymentInterval, - intervalCount: downpaymentIntervalCount, + amount: subscriptionDefinition.recurring.amount, + interval: subscriptionDefinition.recurring.interval, + intervalCount: subscriptionDefinition.recurring.intervalCount, paymentMethodId: stripePaymentMethodId, - startDate: nextDownpaymentDate, - endDate: pricing.subscriptionEndDate || undefined, - description: `Downpayment`, + startDate: subscriptionDefinition.recurring.startDate, + endDate: subscriptionDefinition.recurring.endDate, + description: `'${subscriptionDefinition.name} for order '${order.code}'`, orderCode: order.code, channelToken: ctx.channel.token, }); - createdSubscriptions.push(recurringSubscription.id); if ( - downpaymentSubscription.status !== 'active' && - downpaymentSubscription.status !== 'trialing' + createdSubscription.status !== 'active' && + createdSubscription.status !== 'trialing' ) { + // Created subscription is not active for some reason. Log error and continue to next Logger.error( - `Failed to create active subscription ${downpaymentSubscription.id} for order ${order.code}! It is still in status '${downpaymentSubscription.status}'`, - loggerCtx - ); - await this.logHistoryEntry( - ctx, - order.id, - 'Failed to create downpayment subscription', - 'Failed to create active subscription', - undefined, - downpaymentSubscription.id - ); - } else { - Logger.info( - `Created downpayment subscription ${ - downpaymentSubscription.id - }: ${printMoney( - pricing.downpayment - )} every ${downpaymentIntervalCount} ${downpaymentInterval}(s) with startDate ${ - pricing.subscriptionStartDate - } for order ${order.code}`, + `Failed to create active subscription ${subscriptionDefinition.name} (${createdSubscription.id}) for order ${order.code}! It is still in status '${createdSubscription.status}'`, loggerCtx ); await this.logHistoryEntry( ctx, order.id, - `Created downpayment subscription for line ${orderLineCount}`, - undefined, - pricing, - downpaymentSubscription.id + `Failed to create subscription ${subscriptionDefinition.name}`, + `Subscription status is ${createdSubscription.status}`, + subscriptionDefinition, + createdSubscription.id ); + continue; } + Logger.info( + `Created subscription '${subscriptionDefinition.name}' (${ + createdSubscription.id + }): ${printMoney(subscriptionDefinition.recurring.amount)}`, + loggerCtx + ); + await this.logHistoryEntry( + ctx, + order.id, + `Created subscription for ${subscriptionDefinition.name}`, + undefined, + subscriptionDefinition, + createdSubscription.id + ); + // Add created subscriptions per order line + const existingSubscriptionIds = + subscriptionsPerOrderLine.get(subscriptionDefinition.orderLineId) || + []; + existingSubscriptionIds.push(createdSubscription.id); + subscriptionsPerOrderLine.set( + subscriptionDefinition.orderLineId, + existingSubscriptionIds + ); + } catch (e: unknown) { + await this.logHistoryEntry( + ctx, + order.id, + 'An unknown error occured creating subscriptions', + e + ); + throw e; } - await this.saveSubscriptionIds(ctx, orderLine.id, createdSubscriptions); - } catch (e: unknown) { - await this.logHistoryEntry(ctx, order.id, '', e); - throw e; } + // Save subscriptionIds per order line + for (const [ + orderLineId, + subscriptionIds, + ] of subscriptionsPerOrderLine.entries()) { + await this.saveSubscriptionIds(ctx, orderLineId, subscriptionIds); + } + } catch (e: unknown) { + await this.logHistoryEntry(ctx, order.id, '', e); + throw e; } } + /** + * Save subscriptionIds on order line + */ async saveSubscriptionIds( ctx: RequestContext, orderLineId: ID, subscriptionIds: string[] - ) { + ): Promise { await this.connection .getRepository(ctx, OrderLine) .update({ id: orderLineId }, { customFields: { subscriptionIds } }); @@ -858,22 +860,12 @@ export class StripeSubscriptionService { orderId: ID, message: string, error?: unknown, - pricing?: StripeSubscriptionPricing, + subscription?: Subscription, subscriptionId?: string ): Promise { let prettifiedError = error ? JSON.parse(JSON.stringify(error, Object.getOwnPropertyNames(error))) : undefined; // Make sure its serializable - let prettifierPricing = pricing - ? { - ...pricing, - totalProratedAmount: printMoney(pricing.totalProratedAmount), - downpayment: printMoney(pricing.downpayment), - recurringPrice: printMoney(pricing.recurringPrice), - amountDueNow: printMoney(pricing.amountDueNow), - dayRate: printMoney(pricing.dayRate), - } - : undefined; await this.historyService.createHistoryEntryForOrder( { ctx, @@ -884,7 +876,7 @@ export class StripeSubscriptionService { valid: !error, error: prettifiedError, subscriptionId, - pricing: prettifierPricing, + subscription, }, }, false diff --git a/packages/vendure-plugin-stripe-subscription/src/api/stripe.client.ts b/packages/vendure-plugin-stripe-subscription/src/api/stripe.client.ts index 625b93c5..6c980cf2 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api/stripe.client.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api/stripe.client.ts @@ -1,5 +1,5 @@ +import { Customer } from '@vendure/core'; import Stripe from 'stripe'; -import { CustomerWithSubscriptionFields } from './subscription-custom-fields'; interface SubscriptionInput { customerId: string; @@ -28,17 +28,7 @@ export class StripeClient extends Stripe { super(apiKey, config); } - async getOrCreateCustomer( - customer: CustomerWithSubscriptionFields - ): Promise { - if (customer.customFields?.stripeSubscriptionCustomerId) { - const stripeCustomer = await this.customers.retrieve( - customer.customFields.stripeSubscriptionCustomerId - ); - if (stripeCustomer && !stripeCustomer.deleted) { - return stripeCustomer as Stripe.Customer; - } - } + async getOrCreateCustomer(customer: Customer): Promise { const stripeCustomers = await this.customers.list({ email: customer.emailAddress, }); @@ -82,6 +72,8 @@ export class StripeClient extends Stripe { customer: customerId, // billing_cycle_anchor: this.toStripeTimeStamp(startDate), cancel_at: endDate ? this.toStripeTimeStamp(endDate) : undefined, + // We start the subscription now, but the first payment will be at the start date. + // This is because we can ask the customer to pay the first month during checkout, via one-time-payment trial_end: this.toStripeTimeStamp(startDate), proration_behavior: 'none', description: description, diff --git a/packages/vendure-plugin-stripe-subscription/src/api/subscription-custom-fields.ts b/packages/vendure-plugin-stripe-subscription/src/api/subscription-custom-fields.ts deleted file mode 100644 index 887d2da8..00000000 --- a/packages/vendure-plugin-stripe-subscription/src/api/subscription-custom-fields.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { - Customer, - CustomFieldConfig, - LanguageCode, - Order, - OrderLine, - ProductVariant, -} from '@vendure/core'; -import { Schedule } from './schedule.entity'; -import { StripeSubscriptionPricing } from '../ui/generated/graphql'; - -/** - * Custom fields for managing subscriptions. - * See {@link productVariantCustomFields} for more information on each field - */ -export interface VariantWithSubscriptionFields extends ProductVariant { - customFields: { - subscriptionSchedule?: Schedule; - }; -} - -export interface CustomerWithSubscriptionFields extends Customer { - customFields: { - stripeSubscriptionCustomerId?: string; - }; -} - -export interface OrderLineWithSubscriptionFields extends OrderLine { - subscriptionPricing?: StripeSubscriptionPricing; - customFields: { - downpayment?: number; - startDate?: Date; - subscriptionIds?: string[]; - /** - * Unique hash to separate order lines - */ - subscriptionHash?: string; - }; - productVariant: VariantWithSubscriptionFields; -} - -/** - * An order that can have subscriptions in it - */ -export interface OrderWithSubscriptionFields extends Order { - lines: (OrderLineWithSubscriptionFields & { - productVariant: VariantWithSubscriptionFields; - })[]; - customer: CustomerWithSubscriptionFields; -} - -export const productVariantCustomFields: CustomFieldConfig[] = [ - { - name: 'subscriptionSchedule', - label: [ - { - languageCode: LanguageCode.en, - value: 'Subscription Schedule', - }, - ], - type: 'relation', - entity: Schedule, - graphQLType: 'StripeSubscriptionSchedule', - public: true, - nullable: true, - eager: true, - ui: { component: 'schedule-form-selector', tab: 'Subscription' }, - }, -]; - -export const customerCustomFields: CustomFieldConfig[] = [ - /* ------------ Stripe customer ID -------------------------- */ - { - name: 'stripeSubscriptionCustomerId', - label: [ - { - languageCode: LanguageCode.en, - value: 'Stripe Customer ID', - }, - ], - type: 'string', - public: false, - nullable: true, - ui: { tab: 'Subscription' }, - }, -]; - -export const orderLineCustomFields: CustomFieldConfig[] = [ - { - name: 'downpayment', - label: [ - { - languageCode: LanguageCode.en, - value: 'Downpayment', - }, - ], - type: 'int', - public: true, - nullable: true, - ui: { tab: 'Subscription', component: 'currency-form-input' }, - }, - { - name: 'startDate', - label: [ - { - languageCode: LanguageCode.en, - value: 'Start Date', - }, - ], - type: 'datetime', - public: true, - nullable: true, - ui: { tab: 'Subscription' }, - }, - { - name: 'subscriptionIds', - label: [ - { - languageCode: LanguageCode.en, - value: 'Downpayment', - }, - ], - type: 'string', - list: true, - public: false, - readonly: true, - internal: true, - nullable: true, - }, - { - name: 'subscriptionHash', - label: [ - { - languageCode: LanguageCode.en, - value: 'Subscription hash', - }, - ], - description: [ - { - languageCode: LanguageCode.en, - value: 'Unique hash to separate order lines', - }, - ], - type: 'string', - list: true, - public: false, - readonly: true, - internal: true, - nullable: true, - }, -]; diff --git a/packages/vendure-plugin-stripe-subscription/src/api/subscription-order-item-calculation.ts b/packages/vendure-plugin-stripe-subscription/src/api/subscription-order-item-calculation.ts index f7407b36..70ca42f6 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api/subscription-order-item-calculation.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api/subscription-order-item-calculation.ts @@ -3,44 +3,62 @@ import { Order, OrderItemPriceCalculationStrategy, PriceCalculationResult, + ProductVariant, RequestContext, + UserInputError, } from '@vendure/core'; import { DefaultOrderItemPriceCalculationStrategy } from '@vendure/core/dist/config/order/default-order-item-price-calculation-strategy'; -import { StripeSubscriptionService } from '../api/stripe-subscription.service'; -import { - OrderLineWithSubscriptionFields, - VariantWithSubscriptionFields, -} from './subscription-custom-fields'; +import { CustomOrderLineFields } from '@vendure/core/dist/entity/custom-entity-fields'; +import { StripeSubscriptionService } from './stripe-subscription.service'; -let subcriptionService: StripeSubscriptionService | undefined; +let injector: Injector; export class SubscriptionOrderItemCalculation extends DefaultOrderItemPriceCalculationStrategy implements OrderItemPriceCalculationStrategy { - init(injector: Injector): void | Promise { - subcriptionService = injector.get(StripeSubscriptionService); + init(_injector: Injector): void | Promise { + injector = _injector; } // @ts-ignore - Our strategy takes more arguments, so TS complains that it doesnt match the super.calculateUnitPrice async calculateUnitPrice( ctx: RequestContext, - productVariant: VariantWithSubscriptionFields, - orderLineCustomFields: OrderLineWithSubscriptionFields['customFields'], - order: Order + productVariant: ProductVariant, + orderLineCustomFields: CustomOrderLineFields, + order: Order, + orderLineQuantity: number ): Promise { - if (productVariant.customFields.subscriptionSchedule) { - const pricing = await subcriptionService!.getPricingForVariant(ctx, { - downpayment: orderLineCustomFields.downpayment, - startDate: orderLineCustomFields.startDate, - productVariantId: productVariant.id as string, - }); + const subcriptionService = injector.get(StripeSubscriptionService); + if (!subcriptionService) { + throw new Error('Subscription service not initialized'); + } + if (!subcriptionService.strategy.isSubscription(ctx, productVariant)) { + return super.calculateUnitPrice(ctx, productVariant); + } + const subscription = await subcriptionService.strategy.defineSubscription( + ctx, + injector, + productVariant, + order, + orderLineCustomFields, + orderLineQuantity + ); + if (!Array.isArray(subscription)) { return { - price: pricing.amountDueNow, - priceIncludesTax: productVariant.listPriceIncludesTax, + priceIncludesTax: subscription.priceIncludesTax, + price: subscription.amountDueNow ?? 0, }; - } else { - return super.calculateUnitPrice(ctx, productVariant); } + if (!subscription.length) { + throw Error( + `Subscription strategy returned an empty array. Must contain atleast 1 subscription` + ); + } + const total = subscription.reduce((acc, sub) => sub.amountDueNow || 0, 0); + return { + priceIncludesTax: subscription[0].priceIncludesTax, + price: total, + }; } } diff --git a/packages/vendure-plugin-stripe-subscription/src/api/subscription.promotion.ts b/packages/vendure-plugin-stripe-subscription/src/api/subscription.promotion.ts deleted file mode 100644 index 6b8be3d2..00000000 --- a/packages/vendure-plugin-stripe-subscription/src/api/subscription.promotion.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { ConfigArg } from '@vendure/common/lib/generated-types'; -import { - FacetValueChecker, - ID, - LanguageCode, - OrderLine, - PromotionCondition, - PromotionOrderAction, - PromotionOrderActionConfig, - RequestContext, - TransactionalConnection, -} from '@vendure/core'; -import { - ConfigArgValues, - ConfigArgs, -} from '@vendure/core/dist/common/configurable-operation'; -import { OrderLineWithSubscriptionFields } from './subscription-custom-fields'; - -/** - * Function that executes on the given subscription - */ -type ExecuteOnSubscriptionFn = ( - ctx: RequestContext, - currentSubscriptionPrice: number, - orderLine: OrderLineWithSubscriptionFields, - args: ConfigArgValues -) => Promise; - -/** - * A custom Promotion Action that can discount specific subscription items in an order. - */ -export class SubscriptionPromotionAction< - U extends Array>, - T extends ConfigArgs = ConfigArgs -> extends PromotionOrderAction { - public executeOnSubscriptionFn: ExecuteOnSubscriptionFn; - constructor( - config: Omit, 'execute'> & { - executeOnSubscription: ExecuteOnSubscriptionFn; - } - ) { - super({ - ...config, - execute: () => 0, // No discounts on actual order prices - }); - this.executeOnSubscriptionFn = config.executeOnSubscription; - } - - executeOnSubscription( - ctx: RequestContext, - currentSubscriptionPrice: number, - orderLine: OrderLineWithSubscriptionFields, - args: ConfigArg[] - ): Promise { - return this.executeOnSubscriptionFn( - ctx, - currentSubscriptionPrice, - orderLine, - this.argsArrayToHash(args) - ); - } -} - -export const allByPercentage = new SubscriptionPromotionAction({ - code: 'discount_all_subscription_payments_by_percentage', - description: [ - { - languageCode: LanguageCode.en, - value: 'Discount all subscription payments by { discount } %', - }, - ], - args: { - discount: { - type: 'float', - ui: { - component: 'number-form-input', - suffix: '%', - }, - }, - }, - async executeOnSubscription(ctx, currentSubscriptionPrice, orderLine, args) { - const discount = currentSubscriptionPrice * (args.discount / 100); - return -discount; - }, -}); - -let facetValueChecker: FacetValueChecker; - -export const withFacetsByPercentage = new SubscriptionPromotionAction({ - code: 'discount_subscription_payments_with_facets_by_percentage', - description: [ - { - languageCode: LanguageCode.en, - value: 'Discount subscription payments with facets by { discount } %', - }, - ], - args: { - discount: { - type: 'float', - ui: { - component: 'number-form-input', - suffix: '%', - }, - }, - facets: { - type: 'ID', - list: true, - ui: { component: 'facet-value-form-input' }, - }, - }, - init(injector) { - facetValueChecker = new FacetValueChecker( - injector.get(TransactionalConnection) - ); - }, - async executeOnSubscription(ctx, currentSubscriptionPrice, orderLine, args) { - if (await facetValueChecker.hasFacetValues(orderLine, args.facets, ctx)) { - const discount = currentSubscriptionPrice * (args.discount / 100); - return -discount; - } - return 0; - }, -}); - -export const withFacetsByFixedAmount = new SubscriptionPromotionAction({ - code: 'discount_subscription_payments_with_facets_by_fixed_amount', - description: [ - { - languageCode: LanguageCode.en, - value: 'Discount subscription payments with facets by fixed amount', - }, - ], - args: { - amount: { - type: 'int', - ui: { - component: 'currency-form-input', - }, - }, - facets: { - type: 'ID', - list: true, - ui: { component: 'facet-value-form-input' }, - }, - }, - init(injector) { - facetValueChecker = new FacetValueChecker( - injector.get(TransactionalConnection) - ); - }, - async executeOnSubscription(ctx, currentSubscriptionPrice, orderLine, args) { - if (await facetValueChecker.hasFacetValues(orderLine, args.facets, ctx)) { - const discount = -Math.min(args.amount, currentSubscriptionPrice); // make sure we don't discount more than the current price - return discount; - } - return 0; - }, -}); - -function lineContainsIds(ids: ID[], line: OrderLine): boolean { - return !!ids.find((id) => id == line.productVariant.id); -} - -export const selectedProductsByPercentage = new SubscriptionPromotionAction({ - code: 'discount_subscription_payments_for_selected_products_by_percentage', - description: [ - { - languageCode: LanguageCode.en, - value: - 'Discount subscription payments for selected products by percentage', - }, - ], - args: { - discount: { - type: 'float', - ui: { - component: 'number-form-input', - suffix: '%', - }, - }, - productVariantIds: { - type: 'ID', - list: true, - ui: { component: 'product-selector-form-input' }, - label: [{ languageCode: LanguageCode.en, value: 'Product variants' }], - }, - }, - init(injector) { - facetValueChecker = new FacetValueChecker( - injector.get(TransactionalConnection) - ); - }, - async executeOnSubscription(ctx, currentSubscriptionPrice, orderLine, args) { - if (lineContainsIds(args.productVariantIds, orderLine)) { - const discount = currentSubscriptionPrice * (args.discount / 100); - return -discount; - } - return 0; - }, -}); - -export const selectedProductsByFixedAmount = new SubscriptionPromotionAction({ - code: 'discount_subscription_payments_for_selected_products_by_fixed_amount', - description: [ - { - languageCode: LanguageCode.en, - value: - 'Discount subscription payments for selected products by fixed amount', - }, - ], - args: { - amount: { - type: 'int', - ui: { - component: 'currency-form-input', - }, - }, - productVariantIds: { - type: 'ID', - list: true, - ui: { component: 'product-selector-form-input' }, - label: [{ languageCode: LanguageCode.en, value: 'Product variants' }], - }, - }, - init(injector) { - facetValueChecker = new FacetValueChecker( - injector.get(TransactionalConnection) - ); - }, - async executeOnSubscription(ctx, currentSubscriptionPrice, orderLine, args) { - if (lineContainsIds(args.productVariantIds, orderLine)) { - const discount = -Math.min(args.amount, currentSubscriptionPrice); // make sure we don't discount more than the current price - return discount; - } - return 0; - }, -}); - -export const subscriptionPromotions = [ - allByPercentage, - withFacetsByPercentage, - withFacetsByFixedAmount, - selectedProductsByFixedAmount, - selectedProductsByPercentage, -]; diff --git a/packages/vendure-plugin-stripe-subscription/src/api/types/stripe-invoice.ts b/packages/vendure-plugin-stripe-subscription/src/api/types/stripe-invoice.ts index 92349250..db4526c9 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api/types/stripe-invoice.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api/types/stripe-invoice.ts @@ -1,4 +1,4 @@ -import { Metadata } from './stripe.types'; +import { Metadata } from './stripe.common'; export interface StripeInvoice { id: string; diff --git a/packages/vendure-plugin-stripe-subscription/src/api/types/stripe-payment-intent.ts b/packages/vendure-plugin-stripe-subscription/src/api/types/stripe-payment-intent.ts index 9d2722a0..a6adb72b 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api/types/stripe-payment-intent.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api/types/stripe-payment-intent.ts @@ -1,4 +1,4 @@ -import { Metadata } from './stripe.types'; +import { Metadata } from './stripe.common'; export interface StripePaymentIntent { id: string; @@ -43,6 +43,33 @@ export interface StripePaymentIntent { transfer_group: any; } +export interface StripeSetupIntent { + id: string; + object: string; + application: any; + automatic_payment_methods: any; + cancellation_reason: any; + client_secret: string; + created: number; + customer: string; + description: any; + flow_directions: any; + last_setup_error: any; + latest_attempt: any; + livemode: boolean; + mandate: any; + metadata: Metadata; + next_action: any; + on_behalf_of: any; + payment_method: any; + payment_method_configuration_details: any; + payment_method_options: PaymentMethodOptions; + payment_method_types: string[]; + single_use_mandate: any; + status: string; + usage: string; +} + export interface AmountDetails { tip: Tip; } diff --git a/packages/vendure-plugin-stripe-subscription/src/api/types/stripe.types.ts b/packages/vendure-plugin-stripe-subscription/src/api/types/stripe.common.ts similarity index 100% rename from packages/vendure-plugin-stripe-subscription/src/api/types/stripe.types.ts rename to packages/vendure-plugin-stripe-subscription/src/api/types/stripe.common.ts diff --git a/packages/vendure-plugin-stripe-subscription/src/api/util.ts b/packages/vendure-plugin-stripe-subscription/src/api/util.ts new file mode 100644 index 00000000..65c0c46a --- /dev/null +++ b/packages/vendure-plugin-stripe-subscription/src/api/util.ts @@ -0,0 +1,6 @@ +/** + * Yes, it's real, this helper function prints money for you! + */ +export function printMoney(amount: number): string { + return (amount / 100).toFixed(2); +} diff --git a/packages/vendure-plugin-stripe-subscription/src/api/vendure-config/custom-fields.ts b/packages/vendure-plugin-stripe-subscription/src/api/vendure-config/custom-fields.ts new file mode 100644 index 00000000..a41ed036 --- /dev/null +++ b/packages/vendure-plugin-stripe-subscription/src/api/vendure-config/custom-fields.ts @@ -0,0 +1,40 @@ +import { CustomFieldConfig, LanguageCode } from '@vendure/core'; + +export const orderLineCustomFields: CustomFieldConfig[] = [ + { + name: 'subscriptionIds', + label: [ + { + languageCode: LanguageCode.en, + value: 'Downpayment', + }, + ], + type: 'string', + list: true, + public: false, + readonly: true, + internal: true, + nullable: true, + }, + { + name: 'subscriptionHash', + label: [ + { + languageCode: LanguageCode.en, + value: 'Subscription hash', + }, + ], + description: [ + { + languageCode: LanguageCode.en, + value: 'Unique hash to separate order lines', + }, + ], + type: 'string', + list: true, + public: false, + readonly: true, + internal: true, + nullable: true, + }, +]; diff --git a/packages/vendure-plugin-stripe-subscription/src/api/has-stripe-subscription-products-payment-checker.ts b/packages/vendure-plugin-stripe-subscription/src/api/vendure-config/has-stripe-subscription-products-payment-checker.ts similarity index 52% rename from packages/vendure-plugin-stripe-subscription/src/api/has-stripe-subscription-products-payment-checker.ts rename to packages/vendure-plugin-stripe-subscription/src/api/vendure-config/has-stripe-subscription-products-payment-checker.ts index cac656ae..852ea26d 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api/has-stripe-subscription-products-payment-checker.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api/vendure-config/has-stripe-subscription-products-payment-checker.ts @@ -1,15 +1,12 @@ import { + Injector, LanguageCode, Order, PaymentMethodEligibilityChecker, } from '@vendure/core'; -import { OrderWithSubscriptionFields } from './subscription-custom-fields'; +import { StripeSubscriptionService } from '../stripe-subscription.service'; -export function hasSubscriptions(order: Order): boolean { - return (order as OrderWithSubscriptionFields).lines.some( - (line) => line.productVariant.customFields.subscriptionSchedule - ); -} +let stripeSubscriptionService: StripeSubscriptionService; export const hasStripeSubscriptionProductsPaymentChecker = new PaymentMethodEligibilityChecker({ @@ -17,12 +14,15 @@ export const hasStripeSubscriptionProductsPaymentChecker = description: [ { languageCode: LanguageCode.en, - value: 'Checks that the order has Stripe Subscription products.', + value: 'Checks if the order has Subscription products.', }, ], args: {}, + init: (injector: Injector) => { + stripeSubscriptionService = injector.get(StripeSubscriptionService); + }, check: (ctx, order, args) => { - if (hasSubscriptions(order)) { + if (stripeSubscriptionService?.hasSubscriptionProducts(ctx, order)) { return true; } return false; diff --git a/packages/vendure-plugin-stripe-subscription/src/api/stripe-subscription.handler.ts b/packages/vendure-plugin-stripe-subscription/src/api/vendure-config/stripe-subscription.handler.ts similarity index 81% rename from packages/vendure-plugin-stripe-subscription/src/api/stripe-subscription.handler.ts rename to packages/vendure-plugin-stripe-subscription/src/api/vendure-config/stripe-subscription.handler.ts index 6bc2ae23..f26d1f6f 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api/stripe-subscription.handler.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api/vendure-config/stripe-subscription.handler.ts @@ -7,9 +7,9 @@ import { PaymentMethodHandler, SettlePaymentResult, } from '@vendure/core'; -import { loggerCtx } from '../constants'; -import { printMoney } from './pricing.helper'; -import { StripeSubscriptionService } from './stripe-subscription.service'; +import { loggerCtx } from '../../constants'; +import { StripeSubscriptionService } from '../stripe-subscription.service'; +import { printMoney } from '../util'; let service: StripeSubscriptionService; export const stripeSubscriptionHandler = new PaymentMethodHandler({ @@ -18,32 +18,30 @@ export const stripeSubscriptionHandler = new PaymentMethodHandler({ description: [ { languageCode: LanguageCode.en, - value: 'Use a Stripe Subscription as payment', + value: 'Stripe Subscription', }, ], args: { apiKey: { type: 'string', - label: [{ languageCode: LanguageCode.en, value: 'Stripe API key' }], + label: [{ languageCode: LanguageCode.en, value: 'API key' }], ui: { component: 'password-form-input' }, }, publishableKey: { type: 'string', required: false, - label: [ - { languageCode: LanguageCode.en, value: 'Stripe publishable key' }, - ], + label: [{ languageCode: LanguageCode.en, value: 'Publishable key' }], description: [ { languageCode: LanguageCode.en, - value: - 'You can retrieve this via the "eligiblePaymentMethods.stripeSubscriptionPublishableKey" query in the shop api', + value: 'For use in the storefront only.', }, ], }, webhookSecret: { type: 'string', + required: false, label: [ { languageCode: LanguageCode.en, @@ -54,7 +52,7 @@ export const stripeSubscriptionHandler = new PaymentMethodHandler({ { languageCode: LanguageCode.en, value: - 'Secret to validate incoming webhooks. Get this from your Stripe dashboard', + 'Secret to validate incoming webhooks. Get this from he created webhooks in your Stripe dashboard', }, ], ui: { component: 'password-form-input' }, diff --git a/packages/vendure-plugin-stripe-subscription/src/index.ts b/packages/vendure-plugin-stripe-subscription/src/index.ts index d575e490..604241c9 100644 --- a/packages/vendure-plugin-stripe-subscription/src/index.ts +++ b/packages/vendure-plugin-stripe-subscription/src/index.ts @@ -1,11 +1,9 @@ -export * from './ui/generated/graphql'; -export * from './api/subscription-custom-fields'; -export * from './api/pricing.helper'; -export * from './api/stripe.client'; export * from './stripe-subscription.plugin'; -export * from './api/stripe-subscription.handler'; +export * from './api/generated/graphql'; export * from './api/stripe-subscription.service'; -export * from './api/types/stripe.types'; -export * from './api/stripe-subscription.controller'; -export * from './api/schedule.entity'; -export * from './api/subscription.promotion'; +export * from './api/strategy/subscription-strategy'; +export * from './api/strategy/default-subscription-strategy'; +export * from './api/vendure-config/has-stripe-subscription-products-payment-checker'; +export * from './api/vendure-config/stripe-subscription.handler'; +export * from './types'; +export * from './api/stripe.client'; diff --git a/packages/vendure-plugin-stripe-subscription/src/stripe-subscription.plugin.ts b/packages/vendure-plugin-stripe-subscription/src/stripe-subscription.plugin.ts index a7dc8dd6..8e58d20d 100644 --- a/packages/vendure-plugin-stripe-subscription/src/stripe-subscription.plugin.ts +++ b/packages/vendure-plugin-stripe-subscription/src/stripe-subscription.plugin.ts @@ -1,63 +1,41 @@ import { PluginCommonModule, VendurePlugin } from '@vendure/core'; import { PLUGIN_INIT_OPTIONS } from './constants'; -import { - customerCustomFields, - orderLineCustomFields, - productVariantCustomFields, -} from './api/subscription-custom-fields'; +import { SubscriptionStrategy } from './api/strategy/subscription-strategy'; +import { shopSchemaExtensions } from './api/graphql-schema'; import { createRawBodyMiddleWare } from '../../util/src/raw-body'; -import { SubscriptionOrderItemCalculation } from './api/subscription-order-item-calculation'; -import { Schedule } from './api/schedule.entity'; -import { ScheduleService } from './api/schedule.service'; +import { DefaultSubscriptionStrategy } from './api/strategy/default-subscription-strategy'; import path from 'path'; import { AdminUiExtension } from '@vendure/ui-devkit/compiler'; -import { - adminSchemaExtensions, - shopSchemaExtensions, -} from './api/graphql-schemas'; -import { - AdminPriceIncludesTaxResolver, - AdminResolver, - OrderLinePricingResolver, - ShopResolver, - StripeSubscriptionController, -} from './api/stripe-subscription.controller'; +import { orderLineCustomFields } from './api/vendure-config/custom-fields'; +import { stripeSubscriptionHandler } from './api/vendure-config/stripe-subscription.handler'; +import { hasStripeSubscriptionProductsPaymentChecker } from './api/vendure-config/has-stripe-subscription-products-payment-checker'; +import { SubscriptionOrderItemCalculation } from './api/subscription-order-item-calculation'; import { StripeSubscriptionService } from './api/stripe-subscription.service'; -import { stripeSubscriptionHandler } from './api/stripe-subscription.handler'; -import { hasStripeSubscriptionProductsPaymentChecker } from './api/has-stripe-subscription-products-payment-checker'; -import { subscriptionPromotions } from './api/subscription.promotion'; -import { StripeSubscriptionPayment } from './api/stripe-subscription-payment.entity'; +import { StripeSubscriptionShopResolver } from './api/stripe-subscription.resolver'; +import { StripeSubscriptionController } from './api/stripe-subscription.controller'; export interface StripeSubscriptionPluginOptions { /** * Only use this for testing purposes, NEVER in production */ - disableWebhookSignatureChecking: boolean; + disableWebhookSignatureChecking?: boolean; + vendureHost: string; + subscriptionStrategy?: SubscriptionStrategy; } @VendurePlugin({ imports: [PluginCommonModule], - entities: [Schedule, StripeSubscriptionPayment], shopApiExtensions: { schema: shopSchemaExtensions, - resolvers: [ShopResolver, OrderLinePricingResolver], - }, - adminApiExtensions: { - schema: adminSchemaExtensions, - resolvers: [ - AdminResolver, - AdminPriceIncludesTaxResolver, - OrderLinePricingResolver, - ], + resolvers: [StripeSubscriptionShopResolver], }, controllers: [StripeSubscriptionController], providers: [ - StripeSubscriptionService, - ScheduleService, { provide: PLUGIN_INIT_OPTIONS, useFactory: () => StripeSubscriptionPlugin.options, }, + StripeSubscriptionService, ], configuration: (config) => { config.paymentOptions.paymentMethodHandlers.push(stripeSubscriptionHandler); @@ -65,36 +43,35 @@ export interface StripeSubscriptionPluginOptions { ...(config.paymentOptions.paymentMethodEligibilityCheckers ?? []), hasStripeSubscriptionProductsPaymentChecker, ]; + config.customFields.OrderLine.push(...orderLineCustomFields); config.apiOptions.middleware.push( createRawBodyMiddleWare('/stripe-subscription*') ); config.orderOptions.orderItemPriceCalculationStrategy = new SubscriptionOrderItemCalculation(); - config.customFields.ProductVariant.push(...productVariantCustomFields); - config.customFields.Customer.push(...customerCustomFields); - config.customFields.OrderLine.push(...orderLineCustomFields); - config.promotionOptions.promotionActions.push(...subscriptionPromotions); return config; }, compatibility: '^2.0.0', }) export class StripeSubscriptionPlugin { - static options: StripeSubscriptionPluginOptions; + static options: StripeSubscriptionPluginOptions = { + disableWebhookSignatureChecking: false, + vendureHost: '', + subscriptionStrategy: new DefaultSubscriptionStrategy(), + }; static init(options: StripeSubscriptionPluginOptions) { - this.options = options; + this.options = { + ...this.options, + ...options, + }; return StripeSubscriptionPlugin; } static ui: AdminUiExtension = { + id: 'stripe-subscription-extension', extensionPath: path.join(__dirname, 'ui'), ngModules: [ - { - type: 'lazy', - route: 'stripe', - ngModuleFileName: 'stripe-subscription.module.ts', - ngModuleName: 'SchedulesModule', - }, { type: 'shared', ngModuleFileName: 'stripe-subscription-shared.module.ts', diff --git a/packages/vendure-plugin-stripe-subscription/src/types.ts b/packages/vendure-plugin-stripe-subscription/src/types.ts new file mode 100644 index 00000000..d3a94dc9 --- /dev/null +++ b/packages/vendure-plugin-stripe-subscription/src/types.ts @@ -0,0 +1,14 @@ +// Note: we are using deep a import here, rather than importing from `@vendure/core` due to +// a possible bug in TypeScript (https://github.com/microsoft/TypeScript/issues/46617) which +// causes issues when multiple plugins extend the same custom fields interface. +import { CustomOrderLineFields } from '@vendure/core/dist/entity/custom-entity-fields'; + +declare module '@vendure/core/dist/entity/custom-entity-fields' { + interface CustomOrderLineFields { + subscriptionIds?: string[]; + /** + * Unique hash to separate order lines + */ + subscriptionHash?: string; + } +} diff --git a/packages/vendure-plugin-stripe-subscription/src/ui/history-entry.component.ts b/packages/vendure-plugin-stripe-subscription/src/ui/history-entry.component.ts index c4ba7f0f..574f6471 100644 --- a/packages/vendure-plugin-stripe-subscription/src/ui/history-entry.component.ts +++ b/packages/vendure-plugin-stripe-subscription/src/ui/history-entry.component.ts @@ -4,9 +4,12 @@ import { OrderHistoryEntryComponent, TimelineDisplayType, TimelineHistoryEntry, + SharedModule, } from '@vendure/admin-ui/core'; @Component({ + standalone: true, + imports: [SharedModule], selector: 'stripe-subscription-notification-component', template: ` {{ entry.data.message }} @@ -25,8 +28,11 @@ import { - - + + `, }) @@ -49,6 +55,7 @@ export class HistoryEntryComponent implements OrderHistoryEntryComponent { } getIconShape(entry: TimelineHistoryEntry) { - return entry.data.valid ? undefined : 'exclamation-circle'; + // No icons for not-featured entries + return this.isFeatured(entry) ? 'exclamation-circle' : undefined; } } diff --git a/packages/vendure-plugin-stripe-subscription/src/ui/payments-component/payments-component.html b/packages/vendure-plugin-stripe-subscription/src/ui/payments-component/payments-component.html deleted file mode 100644 index 50693ac3..00000000 --- a/packages/vendure-plugin-stripe-subscription/src/ui/payments-component/payments-component.html +++ /dev/null @@ -1,101 +0,0 @@ - - - - - {{ payment.id }} - - - - {{ payment.createdAt | localeDate : 'short' }} - - - - - {{ payment.updatedAt | localeDate : 'short' }} - - - - - {{ payment.collectionMethod }} - - - - {{ payment.eventType }} - - - - {{ payment.charge | localeCurrency}} - - - - {{ payment.currency }} - - - {{ payment.orderCode }} - - - - Channel - - - - - - {{ payment.subscriptionId }} - - diff --git a/packages/vendure-plugin-stripe-subscription/src/ui/payments-component/payments.component.ts b/packages/vendure-plugin-stripe-subscription/src/ui/payments-component/payments.component.ts deleted file mode 100644 index 1147f8c6..00000000 --- a/packages/vendure-plugin-stripe-subscription/src/ui/payments-component/payments.component.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { Component, OnInit } from '@angular/core'; -import { TypedBaseListComponent } from '@vendure/admin-ui/core'; -import { StripeSubscriptionPaymentsDocument } from '../generated/graphql'; -import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; -import { - DataService, - ModalService, - NotificationService, -} from '@vendure/admin-ui/core'; -@Component({ - selector: 'payments-component', - templateUrl: './payments-component.html', -}) -export class PaymentsComponent - extends TypedBaseListComponent< - typeof StripeSubscriptionPaymentsDocument, - 'stripeSubscriptionPayments' - > - implements OnInit -{ - readonly filters: any = ( - this.createFilterCollection().addDateFilters() as any - ) - .addFilters([ - { - name: 'id', - type: { kind: 'text' }, - label: _('common.id'), - filterField: 'id', - }, - ]) - .connectToRoute(this.route); - readonly sorts: any = this.createSortCollection() - .defaultSort('createdAt', 'DESC') - .addSorts([ - { name: 'id' }, - { name: 'createdAt' }, - { name: 'updatedAt' }, - { name: 'name' }, - { name: 'collectionMethod' }, - { name: 'charge' }, - { name: 'currency' }, - { name: 'orderCode' }, - { name: 'channelId' }, - { name: 'subscriptionId' }, - { name: 'eventType' }, - ]) - .connectToRoute(this.route); - ngOnInit(): void { - super.ngOnInit(); - } - constructor( - protected dataService: DataService, - private modalService: ModalService, - private notificationService: NotificationService - ) { - super(); - this.configure({ - document: StripeSubscriptionPaymentsDocument, - getItems: (data) => data.stripeSubscriptionPayments, - setVariables: (skip, take) => - ({ - options: { - skip, - take, - filter: { - name: { - contains: this.searchTermControl.value, - }, - ...this.filters.createFilterInput(), - }, - sort: this.sorts.createSortInput() as any, - }, - } as any), - refreshListOnChanges: [ - this.sorts.valueChanges, - this.filters.valueChanges, - ], - }); - } -} diff --git a/packages/vendure-plugin-stripe-subscription/src/ui/queries.ts b/packages/vendure-plugin-stripe-subscription/src/ui/queries.ts deleted file mode 100644 index 001c162c..00000000 --- a/packages/vendure-plugin-stripe-subscription/src/ui/queries.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { gql } from 'graphql-tag'; - -export const SCHEDULE_FRAGMENT = gql` - fragment ScheduleFields on StripeSubscriptionSchedule { - id - createdAt - updatedAt - name - downpayment - durationInterval - durationCount - startMoment - billingInterval - billingCount - paidUpFront - fixedStartDate - useProration - autoRenew - } -`; - -export const PAYMENT_FRAGMENT = gql` - fragment PaymentFields on StripeSubscriptionPayment { - id - createdAt - updatedAt - collectionMethod - charge - currency - eventType - orderCode - channelId - subscriptionId - } -`; - -export const GET_SCHEDULES = gql` - ${SCHEDULE_FRAGMENT} - query stripeSubscriptionSchedules { - stripeSubscriptionSchedules { - items { - ...ScheduleFields - } - totalItems - } - } -`; - -export const GET_PAYMENTS = gql` - ${PAYMENT_FRAGMENT} - query stripeSubscriptionPayments { - stripeSubscriptionPayments { - items { - ...PaymentFields - } - totalItems - } - } -`; - -export const UPSERT_SCHEDULES = gql` - ${SCHEDULE_FRAGMENT} - mutation upsertStripeSubscriptionSchedule( - $input: UpsertStripeSubscriptionScheduleInput! - ) { - upsertStripeSubscriptionSchedule(input: $input) { - ...ScheduleFields - } - } -`; - -export const DELETE_SCHEDULE = gql` - mutation deleteStripeSubscriptionSchedule($scheduleId: ID!) { - deleteStripeSubscriptionSchedule(scheduleId: $scheduleId) - } -`; diff --git a/packages/vendure-plugin-stripe-subscription/src/ui/schedule-relation-selector.component.ts b/packages/vendure-plugin-stripe-subscription/src/ui/schedule-relation-selector.component.ts deleted file mode 100644 index 9f6712fd..00000000 --- a/packages/vendure-plugin-stripe-subscription/src/ui/schedule-relation-selector.component.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; -import { FormControl } from '@angular/forms'; -import { ActivatedRoute } from '@angular/router'; -import { RelationCustomFieldConfig } from '@vendure/common/lib/generated-types'; -import { DataService, FormInputComponent } from '@vendure/admin-ui/core'; -import { Observable } from 'rxjs'; -import { StripeSubscriptionSchedule } from './generated/graphql'; -import { GET_SCHEDULES } from './queries'; - -@Component({ - selector: 'schedule-relation-selector', - template: ` -
- Selected: {{ schedule.name }} -
- - `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class ScheduleRelationSelectorComponent - implements OnInit, FormInputComponent -{ - readonly!: boolean; - formControl!: FormControl; - config!: RelationCustomFieldConfig; - - schedules$!: Observable; - - constructor(private dataService: DataService) {} - - ngOnInit() { - this.schedules$ = this.dataService - .query(GET_SCHEDULES) - .mapSingle( - (result: any) => result.stripeSubscriptionSchedules.items ?? [] - ); - } -} diff --git a/packages/vendure-plugin-stripe-subscription/src/ui/schedules-component/schedules.component.html b/packages/vendure-plugin-stripe-subscription/src/ui/schedules-component/schedules.component.html deleted file mode 100644 index f1c6dc2a..00000000 --- a/packages/vendure-plugin-stripe-subscription/src/ui/schedules-component/schedules.component.html +++ /dev/null @@ -1,347 +0,0 @@ - - - -

Stripe Subscription Schedules

-

- Manage subscription schedules here. A schedule can be connected to a - product variant to make it a subscription. -

-
- - - -
-
- - - - - - - {{ schedule.id }} - - - - {{ schedule.createdAt | localeDate : 'short' }} - - - - - {{ schedule.updatedAt | localeDate : 'short' }} - - - - - - {{ schedule.name }} - - - - - - {{ schedule.downpayment | localeCurrency }} - - - - - {{ schedule.durationInterval }} - - - - - {{ schedule.durationCount }} - - - - - {{ schedule.startMoment }} - - - - - {{ schedule.billingInterval }} - - - - - {{ schedule.billingCount }} - - - - - {{ schedule.paidUpFront }} - - - - - {{ schedule.fixedStartDate | localeDate : 'short' }} - - - - - {{ schedule.useProration }} - - - - - {{ schedule.autoRenew }} - - - - - - -
-
-
- - - - - - - - - - - - - - - - every - - - - - - - - - - of the - {{ - form.value.isPaidUpFront - ? form.value.durationInterval - : form.value.billingInterval - }} - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
-
-
diff --git a/packages/vendure-plugin-stripe-subscription/src/ui/schedules-component/schedules.component.scss b/packages/vendure-plugin-stripe-subscription/src/ui/schedules-component/schedules.component.scss deleted file mode 100644 index d1be0d4b..00000000 --- a/packages/vendure-plugin-stripe-subscription/src/ui/schedules-component/schedules.component.scss +++ /dev/null @@ -1,61 +0,0 @@ -.contents-header { - position: sticky; - top: 0; - padding: 6px; - z-index: 1; - border-bottom: 1px solid var(--color-component-border-200); - - .header-title-row { - display: flex; - justify-content: space-between; - align-items: center; - } - - .clr-input { - width: 100%; - } -} - -.stripe-schedules-wrapper { - display: flex; - height: calc(100% - 50px); - - .stripe-schedules-list { - flex: 1; - height: 100%; - overflow: auto; - } - - .stripe-schedules-edit { - height: 100%; - width: 0; - opacity: 0; - visibility: hidden; - overflow: auto; - transition: width 0.3s, opacity 0.2s 0.3s, visibility 0s 0.3s; - - &.expanded { - width: 50vw; - visibility: visible; - opacity: 1; - padding-left: 12px; - } - - .close-button { - margin: 0; - background: none; - border: none; - cursor: pointer; - } - } -} - -.stripe-schedules-edit span { - padding: 5px 5px 0 5px; -} - -.count { - width: 40px; - margin-right: 10px; - flex: none !important; -} diff --git a/packages/vendure-plugin-stripe-subscription/src/ui/schedules-component/schedules.component.ts b/packages/vendure-plugin-stripe-subscription/src/ui/schedules-component/schedules.component.ts deleted file mode 100644 index 9ebcc2b3..00000000 --- a/packages/vendure-plugin-stripe-subscription/src/ui/schedules-component/schedules.component.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { ChangeDetectorRef, Component, OnInit } from '@angular/core'; -import { FormBuilder, FormGroup, Validators } from '@angular/forms'; -import { - DataService, - ModalService, - NotificationService, -} from '@vendure/admin-ui/core'; -import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker'; -import { DELETE_SCHEDULE, GET_SCHEDULES, UPSERT_SCHEDULES } from '../queries'; -import { - StripeSubscriptionSchedule, - StripeSubscriptionSchedulesDocument, - SubscriptionInterval, - SubscriptionStartMoment, -} from '../generated/graphql'; -import { TypedBaseListComponent } from '@vendure/admin-ui/core'; - -@Component({ - selector: 'stripe-subscription-component', - styleUrls: ['./schedules.component.scss'], - templateUrl: './schedules.component.html', -}) -export class SchedulesComponent - extends TypedBaseListComponent< - typeof StripeSubscriptionSchedulesDocument, - 'stripeSubscriptionSchedules' - > - implements OnInit -{ - readonly filters: any = ( - this.createFilterCollection().addDateFilters() as any - ) - .addFilters([ - { - name: 'id', - type: { kind: 'text' }, - label: _('common.id'), - filterField: 'id', - }, - ]) - .connectToRoute(this.route); - readonly sorts: any = this.createSortCollection() - .defaultSort('createdAt', 'DESC') - .addSorts([ - { name: 'id' }, - { name: 'createdAt' }, - { name: 'updatedAt' }, - { name: 'name' }, - { name: 'downpayment' }, - { name: 'durationInterval' }, - { name: 'durationCount' }, - { name: 'startMoment' }, - { name: 'billingInterval' }, - { name: 'billingCount' }, - { name: 'paidUpFront' }, - { name: 'fixedStartDate' }, - { name: 'useProration' }, - { name: 'autoRenew' }, - ]) - .connectToRoute(this.route); - schedules: StripeSubscriptionSchedule[] = []; - selectedSchedule?: StripeSubscriptionSchedule; - page = 1; - itemsPerPage = 10; - form: FormGroup; - currencyCode!: string; - intervals = [SubscriptionInterval.Week, SubscriptionInterval.Month]; - moments = [ - { - name: 'First', - value: SubscriptionStartMoment.StartOfBillingInterval, - }, - { - name: 'Last', - value: SubscriptionStartMoment.EndOfBillingInterval, - }, - { - name: 'Time of purchase', - value: SubscriptionStartMoment.TimeOfPurchase, - }, - { - name: 'Fixed date', - value: SubscriptionStartMoment.FixedStartdate, - }, - ]; - - constructor( - private formBuilder: FormBuilder, - protected dataService: DataService, - private changeDetector: ChangeDetectorRef, - private notificationService: NotificationService, - private modalService: ModalService - ) { - super(); - this.form = this.formBuilder.group({ - name: ['name', Validators.required], - isPaidUpFront: [false], - downpayment: [0, Validators.required], - durationInterval: ['durationInterval', Validators.required], - durationCount: ['durationCount', Validators.required], - startMoment: ['startMoment', Validators.required], - billingInterval: ['billingInterval', Validators.required], - billingCount: ['billingCount', Validators.required], - fixedStartDate: ['fixedStartDate'], - useProration: [false], - autoRenew: [true], - }); - this.configure({ - document: StripeSubscriptionSchedulesDocument, - getItems: (data) => data.stripeSubscriptionSchedules, - setVariables: (skip, take) => - ({ - options: { - skip, - take, - filter: { - name: { - contains: this.searchTermControl.value, - }, - ...this.filters.createFilterInput(), - }, - sort: this.sorts.createSortInput() as any, - }, - } as any), - refreshListOnChanges: [ - this.sorts.valueChanges, - this.filters.valueChanges, - ], - }); - } - get now() { - return new Date().toISOString(); - } - - closeDetail() { - this.selectedSchedule = undefined; - } - - async ngOnInit(): Promise { - // await this.fetchSchedules(); - super.ngOnInit(); - this.dataService.settings.getActiveChannel().single$.subscribe((data) => { - this.currencyCode = data.activeChannel.defaultCurrencyCode; - }); - } - - selectDurationInterval(interval: 'week' | 'month') { - this.form.controls['durationInterval'].setValue(interval); - } - - selectBillingInterval(interval: 'week' | 'month') { - this.form.controls['billingInterval'].setValue(interval); - } - - edit(scheduleId: string): void { - this.items$.subscribe((schedules) => { - this.selectedSchedule = schedules.find((s) => s.id === scheduleId) as any; - if (!this.selectedSchedule) { - return; - } - this.form.controls['name'].setValue(this.selectedSchedule.name); - this.form.controls['downpayment'].setValue( - this.selectedSchedule.downpayment - ); - this.form.controls['durationInterval'].setValue( - this.selectedSchedule.durationInterval - ); - this.form.controls['durationCount'].setValue( - this.selectedSchedule.durationCount - ); - this.form.controls['startMoment'].setValue( - this.selectedSchedule.startMoment - ); - this.form.controls['billingInterval'].setValue( - this.selectedSchedule.billingInterval - ); - this.form.controls['billingCount'].setValue( - this.selectedSchedule.billingCount - ); - this.form.controls['isPaidUpFront'].setValue( - this.selectedSchedule.paidUpFront - ); - this.form.controls['fixedStartDate'].setValue( - this.selectedSchedule.fixedStartDate - ); - this.form.controls['useProration'].setValue( - this.selectedSchedule.useProration - ); - this.form.controls['autoRenew'].setValue(this.selectedSchedule.autoRenew); - }); - } - - newSchedule(): void { - this.selectedSchedule = { - name: 'New schedule', - downpayment: 0, - durationInterval: SubscriptionInterval.Month, - durationCount: 6, - startMoment: SubscriptionStartMoment.StartOfBillingInterval, - billingInterval: SubscriptionInterval.Month, - billingCount: 1, - } as StripeSubscriptionSchedule; - this.form.controls['name'].setValue(this.selectedSchedule.name); - this.form.controls['downpayment'].setValue( - this.selectedSchedule.downpayment - ); - this.form.controls['durationInterval'].setValue( - this.selectedSchedule.durationInterval - ); - this.form.controls['durationCount'].setValue( - this.selectedSchedule.durationCount - ); - this.form.controls['startMoment'].setValue( - this.selectedSchedule.startMoment - ); - this.form.controls['billingInterval'].setValue( - this.selectedSchedule.billingInterval - ); - this.form.controls['billingCount'].setValue( - this.selectedSchedule.billingCount - ); - this.form.controls['billingCount'].setValue( - this.selectedSchedule.billingCount - ); - this.form.controls['fixedStartDate'].setValue(undefined); - } - - async save(): Promise { - try { - if (this.form.dirty) { - const formValue = this.form.value; - if (formValue.isPaidUpFront) { - formValue.downpayment = 0; - // For paid up front duration and billing cycles are the same - formValue.billingInterval = formValue.durationInterval; - formValue.billingCount = formValue.durationCount; - } - if (formValue.startMoment === SubscriptionStartMoment.FixedStartdate) { - formValue.useProration = false; - } - await this.dataService - .mutate(UPSERT_SCHEDULES, { - input: { - id: this.selectedSchedule?.id, - name: formValue.name, - downpayment: formValue.downpayment, - durationInterval: formValue.durationInterval, - durationCount: formValue.durationCount, - startMoment: formValue.startMoment, - billingInterval: formValue.billingInterval, - billingCount: formValue.billingCount, - fixedStartDate: formValue.fixedStartDate, - useProration: formValue.useProration, - autoRenew: formValue.autoRenew, - }, - }) - .toPromise(); - } - this.form.markAsPristine(); - this.changeDetector.markForCheck(); - this.notificationService.success('common.notify-update-success', { - entity: 'Schedule', - }); - } catch (e) { - this.notificationService.error('common.notify-update-error', { - entity: 'Schedule', - }); - } finally { - super.ngOnInit(); - this.selectedSchedule = undefined; - } - } - - deleteSchedule(scheduleId: string): void { - this.modalService - .dialog({ - title: 'Are you sure you want to delete this schedule?', - buttons: [ - { type: 'secondary', label: 'Cancel' }, - { type: 'danger', label: 'Delete', returnValue: true }, - ], - }) - .subscribe(async (confirm) => { - if (confirm) { - await this.dataService - .mutate(DELETE_SCHEDULE, { scheduleId }) - .toPromise(); - this.notificationService.success('Deleted schedule', { - entity: 'Product', - }); - this.selectedSchedule = undefined; - await this.fetchSchedules(); - } - }); - } - - closeEdit() { - this.selectedSchedule = undefined; - } - - async fetchSchedules(): Promise { - this.dataService - .query(GET_SCHEDULES) - .refetchOnChannelChange() - .mapStream((result: any) => result.stripeSubscriptionSchedules) - .subscribe((schedules) => { - this.schedules = schedules.slice( - (this.page - 1) * this.itemsPerPage, - this.itemsPerPage - ); - }); - } - - async setPageNumber(page: number) { - this.page = page; - await this.fetchSchedules(); - } - - async setItemsPerPage(nrOfItems: number) { - this.page = 1; - this.itemsPerPage = Number(nrOfItems); - await this.fetchSchedules(); - } -} diff --git a/packages/vendure-plugin-stripe-subscription/src/ui/stripe-subscription-shared.module.ts b/packages/vendure-plugin-stripe-subscription/src/ui/stripe-subscription-shared.module.ts index 2bddccd8..3bd72a76 100644 --- a/packages/vendure-plugin-stripe-subscription/src/ui/stripe-subscription-shared.module.ts +++ b/packages/vendure-plugin-stripe-subscription/src/ui/stripe-subscription-shared.module.ts @@ -5,41 +5,15 @@ import { registerHistoryEntryComponent, SharedModule, } from '@vendure/admin-ui/core'; -import { ScheduleRelationSelectorComponent } from './schedule-relation-selector.component'; import { HistoryEntryComponent } from './history-entry.component'; @NgModule({ imports: [SharedModule], - declarations: [ScheduleRelationSelectorComponent, HistoryEntryComponent], providers: [ - registerFormInputComponent( - 'schedule-form-selector', - ScheduleRelationSelectorComponent - ), registerHistoryEntryComponent({ type: 'STRIPE_SUBSCRIPTION_NOTIFICATION', component: HistoryEntryComponent, }), - addNavMenuItem( - { - id: 'subscription-schedules', - label: 'Subscriptions schedules', - routerLink: ['/extensions/stripe/subscription-schedules'], - icon: 'calendar', - requiresPermission: 'UpdateSettings', - }, - 'settings' - ), - addNavMenuItem( - { - id: 'subscription-payments', - label: 'Subscriptions payments', - routerLink: ['/extensions/stripe/subscription-payments'], - icon: 'dollar', - requiresPermission: 'ReadOrder', - }, - 'sales' - ), ], }) export class StripeSubscriptionSharedModule {} diff --git a/packages/vendure-plugin-stripe-subscription/src/ui/stripe-subscription.module.ts b/packages/vendure-plugin-stripe-subscription/src/ui/stripe-subscription.module.ts deleted file mode 100644 index 50eccf1d..00000000 --- a/packages/vendure-plugin-stripe-subscription/src/ui/stripe-subscription.module.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { NgModule } from '@angular/core'; -import { RouterModule } from '@angular/router'; -import { SharedModule } from '@vendure/admin-ui/core'; -import { SchedulesComponent } from './schedules-component/schedules.component'; -import { PaymentsComponent } from './payments-component/payments.component'; - -@NgModule({ - imports: [ - SharedModule, - RouterModule.forChild([ - { - path: 'subscription-schedules', - component: SchedulesComponent, - data: { breadcrumb: 'Subscription schedules' }, - }, - { - path: 'subscription-payments', - component: PaymentsComponent, - data: { breadcrumb: 'Subscription payments' }, - }, - ]), - ], - providers: [], - declarations: [SchedulesComponent, PaymentsComponent], -}) -export class SchedulesModule {} diff --git a/packages/vendure-plugin-stripe-subscription/test/dev-server.ts b/packages/vendure-plugin-stripe-subscription/test/dev-server.ts index f1818456..572f2058 100644 --- a/packages/vendure-plugin-stripe-subscription/test/dev-server.ts +++ b/packages/vendure-plugin-stripe-subscription/test/dev-server.ts @@ -1,8 +1,4 @@ -import { - createTestEnvironment, - registerInitializer, - SqljsInitializer, -} from '@vendure/testing'; +import { AdminUiPlugin } from '@vendure/admin-ui-plugin'; import { DefaultLogger, DefaultSearchPlugin, @@ -11,31 +7,27 @@ import { mergeConfig, RequestContextService, } from '@vendure/core'; -import { AdminUiPlugin } from '@vendure/admin-ui-plugin'; -import { StripeTestCheckoutPlugin } from './stripe-test-checkout.plugin'; +import { + createTestEnvironment, + registerInitializer, + SqljsInitializer, +} from '@vendure/testing'; +import { compileUiExtensions } from '@vendure/ui-devkit/compiler'; +import path from 'path'; +import { + StripeSubscriptionIntent, + StripeSubscriptionPlugin, + StripeSubscriptionService, +} from '../src/'; import { ADD_ITEM_TO_ORDER, CREATE_PAYMENT_LINK, CREATE_PAYMENT_METHOD, setShipping, - UPDATE_CHANNEL, - UPDATE_VARIANT, } from './helpers'; +import { StripeTestCheckoutPlugin } from './stripe-test-checkout.plugin'; -import { compileUiExtensions } from '@vendure/ui-devkit/compiler'; -import * as path from 'path'; -import { UPSERT_SCHEDULES } from '../src/ui/queries'; -import { - StripeSubscriptionService, - SubscriptionInterval, - SubscriptionStartMoment, -} from '../src'; - -// Test published version -import { StripeSubscriptionPlugin } from '../src/stripe-subscription.plugin'; -// import { StripeSubscriptionPlugin } from 'vendure-plugin-stripe-subscription'; - -export let clientSecret = 'test'; +export let intent: StripeSubscriptionIntent; /** * Use something like NGROK to start a reverse tunnel to receive webhooks: ngrok http 3050 @@ -50,13 +42,21 @@ export let clientSecret = 'test'; registerInitializer('sqljs', new SqljsInitializer('__data__')); const config = mergeConfig(testConfig, { logger: new DefaultLogger({ level: LogLevel.Debug }), + authOptions: { + cookieOptions: { + secret: '123', + }, + }, apiOptions: { adminApiPlayground: {}, shopApiPlayground: {}, }, plugins: [ StripeTestCheckoutPlugin, - StripeSubscriptionPlugin, + StripeSubscriptionPlugin.init({ + vendureHost: process.env.VENDURE_HOST!, + // subscriptionStrategy: new DownPaymentSubscriptionStrategy(), + }), DefaultSearchPlugin, AdminUiPlugin.init({ port: 3002, @@ -67,10 +67,7 @@ export let clientSecret = 'test'; extensions: [StripeSubscriptionPlugin.ui], devMode: true, }) - : // otherwise used precompiled files. Might need to run once using devMode: false - { - path: path.join(__dirname, '__admin-ui/dist'), - }, + : undefined, }), ], }); @@ -80,158 +77,64 @@ export let clientSecret = 'test'; ...require('../../test/src/initial-data').initialData, shippingMethods: [{ name: 'Standard Shipping', price: 0 }], }, - productsCsvPath: `${__dirname}/subscriptions.csv`, + productsCsvPath: '../test/src/products-import.csv', }); - // Set channel prices to include tax - await adminClient.asSuperAdmin(); - const { - updateChannel: { id }, - } = await adminClient.query(UPDATE_CHANNEL, { - input: { - id: 'T_1', - pricesIncludeTax: true, - }, - }); - console.log('Update channel prices to include tax'); // Create stripe payment method - await adminClient.asSuperAdmin(); - await adminClient.query(CREATE_PAYMENT_METHOD, { - input: { - code: 'stripe-subscription-method', - enabled: true, - handler: { - code: 'stripe-subscription', - arguments: [ - { - name: 'webhookSecret', - value: process.env.STRIPE_WEBHOOK_SECRET, - }, - { name: 'apiKey', value: process.env.STRIPE_APIKEY }, - ], - }, - translations: [ - { - languageCode: LanguageCode.en, - name: 'Stripe test payment', - description: 'This is a Stripe payment method', - }, - ], - }, - }); - console.log(`Created paymentMethod stripe-subscription`); - await adminClient.query(UPSERT_SCHEDULES, { - input: { - name: '6 months, paid in full', - downpayment: 0, - durationInterval: SubscriptionInterval.Month, - durationCount: 6, - startMoment: SubscriptionStartMoment.StartOfBillingInterval, - billingInterval: SubscriptionInterval.Month, - billingCount: 6, - }, - }); - await adminClient.query(UPSERT_SCHEDULES, { - input: { - name: '3 months, billed monthly, 199 downpayment', - downpayment: 0, - durationInterval: SubscriptionInterval.Month, - durationCount: 3, - startMoment: SubscriptionStartMoment.StartOfBillingInterval, - billingInterval: SubscriptionInterval.Week, - billingCount: 1, - }, - }); - const future = new Date('01-01-2024'); - await adminClient.query(UPSERT_SCHEDULES, { - input: { - name: 'Fixed start date, 6 months, billed monthly, 60 downpayment', - downpayment: 6000, - durationInterval: SubscriptionInterval.Month, - durationCount: 6, - startMoment: SubscriptionStartMoment.FixedStartdate, - billingInterval: SubscriptionInterval.Week, - billingCount: 1, - fixedStartDate: future, - }, - }); - console.log(`Created subscription schedules`); - await adminClient.query(UPDATE_VARIANT, { - input: [ - { - id: 1, - customFields: { - subscriptionScheduleId: 1, - }, - }, - ], - }); - await adminClient.query(UPDATE_VARIANT, { - input: [ - { - id: 2, - customFields: { - subscriptionScheduleId: 2, - }, - }, - ], - }); - // await adminClient.query(UPDATE_VARIANT, { - // input: [ - // { - // id: 3, - // customFields: { - // subscriptionScheduleId: 3, - // }, + // await adminClient.asSuperAdmin(); + // await adminClient.query(CREATE_PAYMENT_METHOD, { + // input: { + // code: 'stripe-subscription-method', + // enabled: true, + // handler: { + // code: 'stripe-subscription', + // arguments: [ + // { + // name: 'webhookSecret', + // value: process.env.STRIPE_WEBHOOK_SECRET, + // }, + // { name: 'apiKey', value: process.env.STRIPE_APIKEY }, + // { name: 'publishableKey', value: process.env.STRIPE_PUBLISHABLE_KEY }, + // ], // }, - // ], + // translations: [ + // { + // languageCode: LanguageCode.en, + // name: 'Stripe test payment', + // description: 'This is a Stripe payment method', + // }, + // ], + // }, // }); - console.log(`Added schedule to variants`); - // Prepare order - await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test'); + // console.log(`Created paymentMethod stripe-subscription`); - // This is the variant for checkout - /* await shopClient.query(ADD_ITEM_TO_ORDER, { - productVariantId: '2', - quantity: 1, - customFields: { - // downpayment: 40000, - // startDate: in3Days, - }, - }); */ + await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test'); let { addItemToOrder: order } = await shopClient.query(ADD_ITEM_TO_ORDER, { - productVariantId: '2', - quantity: 1, - customFields: { - // downpayment: 40000, - // startDate: in3Days, - }, - }); - // await shopClient.query(ADD_ITEM_TO_ORDER, { - // productVariantId: '2', - // quantity: 1, - // customFields: { - // // downpayment: 40000, - // // startDate: in3Days, - // }, - // }); - /* await shopClient.query(ADD_ITEM_TO_ORDER, { productVariantId: '1', quantity: 1, - customFields: { - // downpayment: 40000, - // startDate: in3Days, - }, - }); */ - await setShipping(shopClient); - console.log(`Prepared order ${order?.code}`); - const { createStripeSubscriptionIntent: secret } = await shopClient.query( - CREATE_PAYMENT_LINK - ); - clientSecret = secret; - console.log(`Go to http://localhost:3050/checkout/ to test your intent`); + }); + + // await setShipping(shopClient); + // console.log(`Prepared order ${order?.code}`); + + // const { createStripeSubscriptionIntent } = await shopClient.query( + // CREATE_PAYMENT_LINK + // ); + // intent = createStripeSubscriptionIntent; + // console.log( + // `Go to http://localhost:3050/checkout/ to test your ${intent.intentType}` + // ); - // Uncomment these lines to list all subscriptions created in Stripe - // const ctx = await server.app.get(RequestContextService).create({apiType: 'admin'}); - // const subscriptions = await server.app.get(StripeSubscriptionService).getAllSubscriptions(ctx); - // console.log(JSON.stringify(subscriptions)); + const ctx = await server.app + .get(RequestContextService) + .create({ apiType: 'admin' }); + await server.app + .get(StripeSubscriptionService) + .logHistoryEntry( + ctx, + 1, + `Created subscription for TESTING`, + undefined, + { amount: 12345 } as any, + '123' + ); })(); diff --git a/packages/vendure-plugin-stripe-subscription/test/downpayment-subscription-strategy.ts b/packages/vendure-plugin-stripe-subscription/test/downpayment-subscription-strategy.ts new file mode 100644 index 00000000..4b77a3f6 --- /dev/null +++ b/packages/vendure-plugin-stripe-subscription/test/downpayment-subscription-strategy.ts @@ -0,0 +1,88 @@ +import { + RequestContext, + OrderLine, + Injector, + ProductVariant, + Order, +} from '@vendure/core'; +import { Subscription, SubscriptionStrategy } from '../src/'; + +/** + * This strategy creates a monthly subscription + a recurring down payment based on the duration (12 by default) of the subscription: + * * The variant's price is the price per month + * * Down payment will be created as a subscription that renews every 12 months, based on the given duration + */ +export class DownPaymentSubscriptionStrategy implements SubscriptionStrategy { + durationInMonths = 12; + + isSubscription(ctx: RequestContext, variant: ProductVariant): boolean { + // This example treats all products as subscriptions + return true; + } + + defineSubscription( + ctx: RequestContext, + injector: Injector, + productVariant: ProductVariant, + order: Order, + orderLineCustomFields: { [key: string]: any }, + quantity: number + ): Subscription[] { + return this.getSubscriptionsForVariant( + productVariant, + orderLineCustomFields.subscriptionDownpayment, + this.durationInMonths + ); + } + + previewSubscription( + ctx: RequestContext, + injector: Injector, + productVariant: ProductVariant, + customInputs: { + subscriptionDownpayment: number; + } + ): Subscription[] { + return this.getSubscriptionsForVariant( + productVariant, + customInputs.subscriptionDownpayment, + this.durationInMonths + ); + } + + private getSubscriptionsForVariant( + productVariant: ProductVariant, + downpayment: number, + durationInMonths: number + ): Subscription[] { + const discountPerMonth = downpayment / durationInMonths; + const subscriptions: Subscription[] = []; + subscriptions.push({ + name: `Monthly subscription - ${productVariant.name}`, + variantId: productVariant.id, + priceIncludesTax: productVariant.listPriceIncludesTax, + amountDueNow: 0, + recurring: { + amount: productVariant.listPrice - discountPerMonth, + interval: 'month', + intervalCount: 1, + startDate: new Date(), + }, + }); + if (downpayment > 0) { + subscriptions.push({ + name: `Downpayment subscription - ${productVariant.name}`, + variantId: productVariant.id, + priceIncludesTax: productVariant.listPriceIncludesTax, + amountDueNow: 0, + recurring: { + amount: downpayment, + interval: 'month', + intervalCount: durationInMonths, + startDate: new Date(), + }, + }); + } + return subscriptions; + } +} diff --git a/packages/vendure-plugin-stripe-subscription/test/helpers.ts b/packages/vendure-plugin-stripe-subscription/test/helpers.ts index 9469f660..ed0a5d65 100644 --- a/packages/vendure-plugin-stripe-subscription/test/helpers.ts +++ b/packages/vendure-plugin-stripe-subscription/test/helpers.ts @@ -1,23 +1,16 @@ import { gql } from 'graphql-tag'; import { SimpleGraphQLClient } from '@vendure/testing'; -import { SCHEDULE_FRAGMENT } from '../src/ui/queries'; import { ChannelService, RequestContext } from '@vendure/core'; import { TestServer } from '@vendure/testing/lib/test-server'; export const ADD_ITEM_TO_ORDER = gql` - mutation AddItemToOrder( - $productVariantId: ID! - $quantity: Int! - $customFields: OrderLineCustomFieldsInput - ) { - addItemToOrder( - productVariantId: $productVariantId - quantity: $quantity - customFields: $customFields - ) { + mutation AddItemToOrder($productVariantId: ID!, $quantity: Int!) { + addItemToOrder(productVariantId: $productVariantId, quantity: $quantity) { ... on Order { id code + totalWithTax + total } ... on ErrorResult { errorCode @@ -27,102 +20,24 @@ export const ADD_ITEM_TO_ORDER = gql` } `; -export const REMOVE_ORDERLINE = gql` - mutation CancelOrder($input: CancelOrderInput!) { - cancelOrder(input: $input) { - __typename - } - } -`; - -export const REFUND_ORDER = gql` - mutation RefundOrder($input: RefundOrderInput!) { - refundOrder(input: $input) { - __typename - } - } -`; - -export const REMOVE_ALL_ORDERLINES = gql` - mutation { - removeAllOrderLines { - ... on Order { - id - } - } - } -`; - -export const UPDATE_CHANNEL = gql` - mutation UpdateChannel($input: UpdateChannelInput!) { - updateChannel(input: $input) { - ... on Channel { - id - } - } - } -`; - -export const GET_PRICING = gql` - ${SCHEDULE_FRAGMENT} - query stripeSubscriptionPricing($input: StripeSubscriptionPricingInput) { - stripeSubscriptionPricing(input: $input) { - downpayment - totalProratedAmount - proratedDays - dayRate - recurringPrice - interval - intervalCount - amountDueNow - subscriptionStartDate - schedule { - ...ScheduleFields - } - } - } -`; - -export const GET_PRICING_FOR_PRODUCT = gql` - ${SCHEDULE_FRAGMENT} - query stripeSubscriptionPricingForProduct($productId: ID!) { - stripeSubscriptionPricingForProduct(productId: $productId) { - downpayment - totalProratedAmount - proratedDays - dayRate - recurringPrice - interval - intervalCount - amountDueNow - subscriptionStartDate - schedule { - ...ScheduleFields - } +export const CREATE_PAYMENT_METHOD = gql` + mutation CreatePaymentMethod($input: CreatePaymentMethodInput!) { + createPaymentMethod(input: $input) { + id } } `; -export const GET_ORDER_WITH_PRICING = gql` - ${SCHEDULE_FRAGMENT} - query getOrderWithPricing { - activeOrder { - id - code - lines { - subscriptionPricing { - downpayment - totalProratedAmount - proratedDays - dayRate - recurringPrice - originalRecurringPrice - interval - intervalCount - amountDueNow - subscriptionStartDate - schedule { - ...ScheduleFields +export const GET_PAYMENT_METHODS = gql` + query paymentMethods { + paymentMethods { + items { + code + handler { + args { + name + value + __typename } } } @@ -130,14 +45,6 @@ export const GET_ORDER_WITH_PRICING = gql` } `; -export const CREATE_PAYMENT_METHOD = gql` - mutation CreatePaymentMethod($input: CreatePaymentMethodInput!) { - createPaymentMethod(input: $input) { - id - } - } -`; - export const SET_SHIPPING_ADDRESS = gql` mutation SetShippingAddress($input: CreateAddressInput!) { setOrderShippingAddress(input: $input) { @@ -179,52 +86,71 @@ export const SET_SHIPPING_METHOD = gql` export const CREATE_PAYMENT_LINK = gql` mutation createStripeSubscriptionIntent { - createStripeSubscriptionIntent + createStripeSubscriptionIntent { + clientSecret + intentType + } } `; -export const GET_SCHEDULES = gql` - { - stripeSubscriptionSchedules { - items { - id - createdAt - updatedAt - name - downpayment - durationInterval - durationCount - startMoment - paidUpFront - billingInterval - billingCount +export const ELIGIBLE_PAYMENT_METHODS = gql` + query eligiblePaymentMethods { + eligiblePaymentMethods { + id + name + stripeSubscriptionPublishableKey + } + } +`; + +export const PREVIEW_SUBSCRIPTIONS = gql` + query previewStripeSubscriptions($productVariantId: ID!) { + previewStripeSubscriptions(productVariantId: $productVariantId) { + name + amountDueNow + variantId + priceIncludesTax + recurring { + amount + interval + intervalCount + startDate + endDate } } } `; -export const UPDATE_VARIANT = gql` - mutation updateProductVariants($input: [UpdateProductVariantInput!]!) { - updateProductVariants(input: $input) { - ... on ProductVariant { - id - customFields { - subscriptionSchedule { - id - } - } +export const PREVIEW_SUBSCRIPTIONS_FOR_PRODUCT = gql` + query previewStripeSubscriptionsForProduct($productId: ID!) { + previewStripeSubscriptionsForProduct(productId: $productId) { + name + amountDueNow + variantId + priceIncludesTax + recurring { + amount + interval + intervalCount + startDate + endDate } + } + } +`; + +export const CANCEL_ORDER = gql` + mutation CancelOrder($input: CancelOrderInput!) { + cancelOrder(input: $input) { __typename } } `; -export const ELIGIBLE_PAYMENT_METHODS = gql` - query eligiblePaymentMethods { - eligiblePaymentMethods { - id - name - stripeSubscriptionPublishableKey +export const REFUND_ORDER = gql` + mutation RefundOrder($input: RefundOrderInput!) { + refundOrder(input: $input) { + __typename } } `; diff --git a/packages/vendure-plugin-stripe-subscription/test/stripe-subscription.spec.ts b/packages/vendure-plugin-stripe-subscription/test/stripe-subscription.spec.ts index 35674580..aca2801a 100644 --- a/packages/vendure-plugin-stripe-subscription/test/stripe-subscription.spec.ts +++ b/packages/vendure-plugin-stripe-subscription/test/stripe-subscription.spec.ts @@ -1,7 +1,6 @@ import { DefaultLogger, EventBus, - HistoryService, LogLevel, mergeConfig, Order, @@ -9,6 +8,7 @@ import { OrderService, OrderStateTransitionEvent, } from '@vendure/core'; +import { SubscribableJob } from '@vendure/core/dist/job-queue/subscribable-job'; import { createTestEnvironment, registerInitializer, @@ -17,57 +17,33 @@ import { testConfig, } from '@vendure/testing'; import { TestServer } from '@vendure/testing/lib/test-server'; +import gql from 'graphql-tag'; // @ts-ignore import nock from 'nock'; // @ts-ignore -import { createPromotion, getOrder } from '../../test/src/admin-utils'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { getOrder } from '../../test/src/admin-utils'; import { initialData } from '../../test/src/initial-data'; -import { applyCouponCode } from '../../test/src/shop-utils'; -import { - allByPercentage, - calculateSubscriptionPricing, - getBillingsPerDuration, - getDayRate, - getDaysUntilNextStartDate, - getNextCyclesStartDate, - getNextStartDate, - IncomingStripeWebhook, - OrderLineWithSubscriptionFields, - Schedule, - StripeSubscriptionPlugin, - StripeSubscriptionPricing, - StripeSubscriptionService, - SubscriptionInterval, - SubscriptionStartMoment, - VariantForCalculation, -} from '../src'; -import { DELETE_SCHEDULE, UPSERT_SCHEDULES } from '../src/ui/queries'; +import { stripeSubscriptionHandler, StripeSubscriptionPlugin } from '../src'; import { ADD_ITEM_TO_ORDER, + CANCEL_ORDER, CREATE_PAYMENT_LINK, CREATE_PAYMENT_METHOD, - GET_ORDER_WITH_PRICING, - GET_PRICING, - GET_PRICING_FOR_PRODUCT, - GET_SCHEDULES, + ELIGIBLE_PAYMENT_METHODS, getDefaultCtx, + GET_PAYMENT_METHODS, + PREVIEW_SUBSCRIPTIONS, + PREVIEW_SUBSCRIPTIONS_FOR_PRODUCT, REFUND_ORDER, - REMOVE_ORDERLINE, setShipping, - UPDATE_CHANNEL, - UPDATE_VARIANT, - ELIGIBLE_PAYMENT_METHODS, } from './helpers'; -import { expect, describe, beforeAll, afterAll, it, vi, test } from 'vitest'; -import { gql } from 'graphql-tag'; describe('Stripe Subscription Plugin', function () { let server: TestServer; let adminClient: SimpleGraphQLClient; let shopClient: SimpleGraphQLClient; let serverStarted = false; - let order: Order | undefined; - let orderEvents: (OrderStateTransitionEvent | OrderPlacedEvent)[] = []; beforeAll(async () => { registerInitializer('sqljs', new SqljsInitializer('__data__')); @@ -76,22 +52,30 @@ describe('Stripe Subscription Plugin', function () { plugins: [ StripeSubscriptionPlugin.init({ disableWebhookSignatureChecking: true, + vendureHost: 'https://public-test-host.io', }), ], }); ({ server, adminClient, shopClient } = createTestEnvironment(config)); await server.init({ initialData, - productsCsvPath: `${__dirname}/subscriptions.csv`, + productsCsvPath: '../test/src/products-import.csv', }); serverStarted = true; }, 60000); + afterEach(async () => { + nock.cleanAll(); + }); + it('Should start successfully', async () => { expect(serverStarted).toBe(true); }); + let orderEvents: (OrderStateTransitionEvent | OrderPlacedEvent)[] = []; + it('Listens for OrderPlacedEvent and OrderStateTransitionEvents', async () => { + // Used to test if all events have RequestContext defined server.app .get(EventBus) .ofType(OrderPlacedEvent) @@ -106,969 +90,279 @@ describe('Stripe Subscription Plugin', function () { }); }); - const ctx = { channel: { pricesIncludeTax: true } }; - - it("Sets channel settings to 'prices are including tax'", async () => { - await adminClient.asSuperAdmin(); - const { - updateChannel: { id }, - } = await adminClient.query(UPDATE_CHANNEL, { - input: { - id: 'T_1', - pricesIncludeTax: true, - }, - }); - expect(id).toBe('T_1'); - }); - - it('Creates Stripe subscription method', async () => { + const createdWebhooks: any[] = []; + + it('Creates Stripe Subscription payment method', async () => { + // Catch outgoing webhook creation requests + nock('https://api.stripe.com') + .get(/webhook_endpoints.*/) + .reply(200, { data: [] }); + nock('https://api.stripe.com') + .post(/webhook_endpoints.*/, (body) => { + createdWebhooks.push(body); + return true; + }) + .reply(200, { + secret: 'whsec_testing', + }); await adminClient.asSuperAdmin(); - await adminClient.query(CREATE_PAYMENT_METHOD, { - input: { - translations: [ - { - languageCode: 'en', - name: 'Stripe test payment', - description: 'This is a Stripe payment method', - }, - ], - code: 'stripe-subscription-method', - enabled: true, - checker: { - code: 'has-stripe-subscription-products-checker', - arguments: [], - }, - handler: { - code: 'stripe-subscription', - arguments: [ + const { createPaymentMethod } = await adminClient.query( + CREATE_PAYMENT_METHOD, + { + input: { + translations: [ { - name: 'webhookSecret', - value: 'testsecret', + languageCode: 'en', + name: 'Stripe test payment', + description: 'This is a Stripe payment method', }, - { name: 'apiKey', value: 'test-api-key' }, - { name: 'publishableKey', value: 'test-publishable-key' }, ], + code: 'stripe-subscription-method', + enabled: true, + checker: { + code: 'has-stripe-subscription-products-checker', + arguments: [], + }, + handler: { + code: stripeSubscriptionHandler.code, + arguments: [ + { + name: 'webhookSecret', + value: '', + }, + { name: 'apiKey', value: 'test-api-key' }, + { name: 'publishableKey', value: 'test-publishable-key' }, + ], + }, }, + } + ); + await new Promise((resolve) => setTimeout(resolve, 1000)); // Await asyncronous webhook creation + expect(createPaymentMethod.id).toBe('T_1'); + }); + + it('Created webhooks and saved webhook secrets', async () => { + const { paymentMethods } = await adminClient.query(GET_PAYMENT_METHODS); + const webhookSecret = paymentMethods.items[0].handler.args.find( + (a) => a.name === 'webhookSecret' + )?.value; + expect(createdWebhooks.length).toBe(1); + expect(paymentMethods.items[0].code).toBe('stripe-subscription-method'); + expect(webhookSecret).toBe('whsec_testing'); + }); + + it('Previews subscription for variant', async () => { + const { + previewStripeSubscriptions: [subscription], + } = await shopClient.query(PREVIEW_SUBSCRIPTIONS, { + productVariantId: 'T_1', + }); + const now = new Date(); + const startDate = new Date( + now.getFullYear(), + now.getMonth() + 1, + now.getDate(), + 12 + ); + expect(subscription).toEqual({ + name: 'Subscription Laptop 13 inch 8GB', + amountDueNow: 129900, + variantId: 'T_1', + priceIncludesTax: false, + recurring: { + amount: 129900, + interval: 'month', + intervalCount: 1, + startDate: startDate.toISOString(), + endDate: null, }, }); }); - describe('Get next cycles start date', () => { - test.each([ - [ - 'Start of the month, in 12 months', - new Date('2022-12-20T12:00:00.000Z'), - SubscriptionStartMoment.StartOfBillingInterval, - 12, - SubscriptionInterval.Month, - '2024-01-01', - ], - [ - 'End of the month, in 6 months', - new Date('2022-12-20T12:00:00.000Z'), - SubscriptionStartMoment.EndOfBillingInterval, - 6, - SubscriptionInterval.Month, - '2023-06-30', - ], - [ - 'Time of purchase, in 8 weeks', - new Date('2022-12-20'), - SubscriptionStartMoment.TimeOfPurchase, - 8, - SubscriptionInterval.Week, - '2023-02-14', - ], - ])( - 'Calculates next cycles start date for "%s"', - ( - _message: string, - now: Date, - startDate: SubscriptionStartMoment, - intervalCount: number, - interval: SubscriptionInterval, - expected: string - ) => { - expect( - getNextCyclesStartDate(now, startDate, interval, intervalCount) - .toISOString() - .split('T')[0] - ).toEqual(expected); - } - ); + it('Preview subscriptions for product', async () => { + const { previewStripeSubscriptionsForProduct: subscriptions } = + await shopClient.query(PREVIEW_SUBSCRIPTIONS_FOR_PRODUCT, { + productId: 'T_1', + }); + expect(subscriptions.length).toBe(4); }); - describe('Calculate day rate', () => { - test.each([ - [20000, 6, SubscriptionInterval.Month, 110], - [80000, 24, SubscriptionInterval.Month, 110], - [20000, 26, SubscriptionInterval.Week, 110], - [40000, 52, SubscriptionInterval.Week, 110], - ])( - 'Day rate for $%i per %i %s should be $%i', - ( - price: number, - count: number, - interval: SubscriptionInterval, - expected: number - ) => { - expect(getDayRate(price, interval, count)).toBe(expected); + let orderCode; + + it('Adds item to order', async () => { + await shopClient.asUserWithCredentials( + 'hayden.zieme12@hotmail.com', + 'test' + ); + const { addItemToOrder: order } = await shopClient.query( + ADD_ITEM_TO_ORDER, + { + productVariantId: 'T_1', + quantity: 1, } ); + orderCode = order.code; + expect(order.total).toBe(129900); }); - describe('Calculate billings per duration', () => { - test.each([ - [SubscriptionInterval.Week, 1, SubscriptionInterval.Month, 3, 12], - [SubscriptionInterval.Week, 2, SubscriptionInterval.Month, 1, 2], - [SubscriptionInterval.Week, 3, SubscriptionInterval.Month, 3, 4], - [SubscriptionInterval.Month, 3, SubscriptionInterval.Month, 6, 2], - ])( - 'for %sly %s', - ( - billingInterval: SubscriptionInterval, - billingCount: number, - durationInterval: SubscriptionInterval, - durationCount: number, - expected: number - ) => { - expect( - getBillingsPerDuration({ - billingInterval, - billingCount, - durationCount, - durationInterval, - }) - ).toBe(expected); - } - ); + it('Sets a shipping method and customer details', async () => { + await setShipping(shopClient); }); - describe('Calculate nr of days until next subscription start date', () => { - test.each([ - [ - new Date('2022-12-20'), - SubscriptionStartMoment.StartOfBillingInterval, - SubscriptionInterval.Month, - undefined, - 12, - ], - [ - new Date('2022-12-20'), - SubscriptionStartMoment.EndOfBillingInterval, - SubscriptionInterval.Month, - undefined, - 11, - ], - [ - new Date('2022-12-20'), - SubscriptionStartMoment.StartOfBillingInterval, - SubscriptionInterval.Week, - undefined, - 5, - ], - [ - new Date('2022-12-20'), - SubscriptionStartMoment.EndOfBillingInterval, - SubscriptionInterval.Week, - undefined, - 4, - ], - [ - new Date('2022-12-20'), - SubscriptionStartMoment.TimeOfPurchase, - SubscriptionInterval.Week, - undefined, - 0, - ], - [ - new Date('2022-12-20'), - SubscriptionStartMoment.FixedStartdate, - SubscriptionInterval.Week, - new Date('2022-12-22'), - 2, - ], - ])( - 'Calculate days: from %s to "%s" of %s should be %i', - ( - now: Date, - startDate: SubscriptionStartMoment, - interval: SubscriptionInterval, - fixedStartDate: Date | undefined, - expected: number - ) => { - const nextStartDate = getNextStartDate( - now, - interval, - startDate, - fixedStartDate - ); - expect(getDaysUntilNextStartDate(now, nextStartDate)).toBe(expected); - } + it('Exposes publishable key via eligible payment methods', async () => { + const { eligiblePaymentMethods } = await shopClient.query( + ELIGIBLE_PAYMENT_METHODS + ); + expect(eligiblePaymentMethods[0].stripeSubscriptionPublishableKey).toBe( + 'test-publishable-key' ); }); - it('Creates a paid-up-front subscription for variant 1 ($540)', async () => { - const { upsertStripeSubscriptionSchedule: schedule } = - await adminClient.query(UPSERT_SCHEDULES, { - input: { - name: '6 months, paid in full', - downpayment: 0, - durationInterval: SubscriptionInterval.Month, - durationCount: 6, - startMoment: SubscriptionStartMoment.StartOfBillingInterval, - billingInterval: SubscriptionInterval.Month, - billingCount: 6, - useProration: true, - autoRenew: true, - }, + it('Created a PaymentIntent', async () => { + // Mock API + let paymentIntentInput: any = {}; + nock('https://api.stripe.com') + .get(/customers.*/) + .reply(200, { data: [{ id: 'customer-test-id' }] }); + nock('https://api.stripe.com') + .post(/payment_intents.*/, (body) => { + paymentIntentInput = body; + return true; + }) + .reply(200, { + client_secret: 'mock-secret-1234', + object: 'payment_intent', }); - const { - updateProductVariants: [variant], - } = await adminClient.query(UPDATE_VARIANT, { - input: [ - { - id: 1, - customFields: { - subscriptionScheduleId: schedule.id, - }, - }, - ], - }); - expect(schedule.id).toBeDefined(); - expect(schedule.createdAt).toBeDefined(); - expect(schedule.name).toBe('6 months, paid in full'); - expect(schedule.downpayment).toBe(0); - expect(schedule.paidUpFront).toBe(true); - expect(schedule.durationInterval).toBe(schedule.billingInterval); // duration and billing should be equal for paid up front subs - expect(schedule.durationCount).toBe(schedule.billingCount); - expect(schedule.startMoment).toBe( - SubscriptionStartMoment.StartOfBillingInterval + const { createStripeSubscriptionIntent: intent } = await shopClient.query( + CREATE_PAYMENT_LINK ); - expect(schedule.useProration).toBe(true); - expect(schedule.useProration).toBe(true); - expect(variant.id).toBe(schedule.id); + expect(intent.clientSecret).toBe('mock-secret-1234'); + expect(intent.intentType).toBe('PaymentIntent'); + expect(paymentIntentInput.setup_future_usage).toBe('off_session'); + expect(paymentIntentInput.customer).toBe('customer-test-id'); + expect(paymentIntentInput.amount).toBe('156380'); }); - it('Creates a 3 month, billed weekly subscription for variant 2 ($90)', async () => { - const { upsertStripeSubscriptionSchedule: schedule } = - await adminClient.query(UPSERT_SCHEDULES, { - input: { - name: '6 months, billed monthly, 199 downpayment', - downpayment: 19900, - durationInterval: SubscriptionInterval.Month, - durationCount: 3, - startMoment: SubscriptionStartMoment.StartOfBillingInterval, - billingInterval: SubscriptionInterval.Week, - billingCount: 1, - autoRenew: false, - }, + let createdSubscriptions: any[] = []; + + it('Settles order on incoming succeeded webhook', async () => { + // Mock API + nock('https://api.stripe.com') + .get(/customers.*/) + .reply(200, { data: [{ id: 'customer-test-id' }] }); + nock('https://api.stripe.com') + .post(/products.*/) + .reply(200, { + id: 'test-product', + }) + .persist(true); + nock('https://api.stripe.com') + .post(/subscriptions.*/, (body) => { + createdSubscriptions.push(body); + return true; + }) + .times(3) + .reply(200, { + id: 'mock-sub', + status: 'active', }); - const { - updateProductVariants: [variant], - } = await adminClient.query(UPDATE_VARIANT, { - input: [ - { - id: 2, - customFields: { - subscriptionScheduleId: schedule.id, + let adminOrder = await getOrder(adminClient, '1'); + await adminClient.fetch( + 'http://localhost:3050/stripe-subscriptions/webhook', + { + method: 'POST', + body: JSON.stringify({ + type: 'payment_intent.succeeded', + data: { + object: { + customer: 'mock', + metadata: { + orderCode, + channelToken: 'e2e-default-channel', + amount: adminOrder!.totalWithTax, + }, + }, }, - }, - ], - }); - expect(schedule.id).toBeDefined(); - expect(schedule.createdAt).toBeDefined(); - expect(schedule.name).toBe('6 months, billed monthly, 199 downpayment'); - expect(schedule.downpayment).toBe(19900); - expect(schedule.durationInterval).toBe(SubscriptionInterval.Month); - expect(schedule.durationCount).toBe(3); - expect(schedule.billingInterval).toBe(SubscriptionInterval.Week); - expect(schedule.billingCount).toBe(1); - expect(schedule.startMoment).toBe( - SubscriptionStartMoment.StartOfBillingInterval + }), + } ); - expect(variant.id).toBe(schedule.id); + await new Promise((resolve) => setTimeout(resolve, 500)); + adminOrder = await getOrder(adminClient, '1'); + expect(adminOrder?.state).toBe('PaymentSettled'); }); - describe('Pricing calculations', () => { - it('Should calculate default pricing for recurring subscription (variant 2 - $90 per week)', async () => { - // Uses the default downpayment of $199 - const { stripeSubscriptionPricing } = await shopClient.query( - GET_PRICING, - { - input: { - productVariantId: 2, - }, - } - ); - const pricing: StripeSubscriptionPricing = stripeSubscriptionPricing; - expect(pricing.downpayment).toBe(19900); - expect(pricing.recurringPrice).toBe(9000); - expect(pricing.interval).toBe('week'); - expect(pricing.intervalCount).toBe(1); - expect(pricing.dayRate).toBe(1402); - expect(pricing.amountDueNow).toBe(pricing.totalProratedAmount + 19900); - expect(pricing.schedule.name).toBe( - '6 months, billed monthly, 199 downpayment' - ); - }); - - it('Should calculate pricing for recurring with $400 custom downpayment', async () => { - const { stripeSubscriptionPricing } = await shopClient.query( - GET_PRICING, - { - input: { - productVariantId: 2, - downpayment: 40000, - }, - } - ); - // Default price is $90 a month with a downpayment of $199 - // With a downpayment of $400, the price should be ($400 - $199) / 12 = $16.75 lower, so $73,25 - const pricing: StripeSubscriptionPricing = stripeSubscriptionPricing; - expect(pricing.downpayment).toBe(40000); - expect(pricing.recurringPrice).toBe(7325); - expect(pricing.interval).toBe('week'); - expect(pricing.intervalCount).toBe(1); - expect(pricing.dayRate).toBe(1402); - expect(pricing.amountDueNow).toBe(pricing.totalProratedAmount + 40000); - }); - - it('Should throw an error when downpayment is below the schedules default', async () => { - let error = ''; - await shopClient - .query(GET_PRICING, { - input: { - productVariantId: 2, - downpayment: 0, - }, - }) - .catch((e) => (error = e.message)); - expect(error).toContain('Downpayment cannot be lower than'); - }); - - it('Should throw an error when downpayment is higher than the total subscription value', async () => { - let error = ''; - await shopClient - .query(GET_PRICING, { - input: { - productVariantId: 2, - downpayment: 990000, // max is 1080 + 199 = 1279 - }, - }) - .catch((e) => (error = e.message)); - expect(error).toContain('Downpayment cannot be higher than'); - }); - - it('Should throw error when trying to use a downpayment for paid up front', async () => { - let error = ''; - await shopClient - .query(GET_PRICING, { - input: { - productVariantId: 1, - downpayment: 19900, // max is 540 + 199 = 739 - }, - }) - .catch((e) => (error = e.message)); - expect(error).toContain( - 'You can not use downpayments with Paid-up-front subscriptions' - ); - }); - - it('Should calculate pricing for recurring with custom downpayment and custom startDate', async () => { - // Uses the default downpayment of $199 - const in3Days = new Date(); - in3Days.setDate(in3Days.getDate() + 3); - const { stripeSubscriptionPricing } = await shopClient.query( - GET_PRICING, - { - input: { - productVariantId: 2, - downpayment: 40000, - startDate: in3Days.toISOString(), - }, - } - ); - const pricing: StripeSubscriptionPricing = stripeSubscriptionPricing; - expect(pricing.downpayment).toBe(40000); - expect(pricing.recurringPrice).toBe(7325); - expect(pricing.interval).toBe('week'); - expect(pricing.intervalCount).toBe(1); - expect(pricing.dayRate).toBe(1402); - expect(pricing.amountDueNow).toBe(pricing.totalProratedAmount + 40000); - }); - - it('Should calculate pricing for each variant of product', async () => { - const { stripeSubscriptionPricingForProduct } = await shopClient.query( - GET_PRICING_FOR_PRODUCT, - { - productId: 1, - } - ); - const pricing: StripeSubscriptionPricing[] = - stripeSubscriptionPricingForProduct; - expect(pricing[1].downpayment).toBe(19900); - expect(pricing[1].recurringPrice).toBe(9000); - expect(pricing[1].interval).toBe('week'); - expect(pricing[1].intervalCount).toBe(1); - expect(pricing[1].dayRate).toBe(1402); - expect(pricing[1].amountDueNow).toBe( - pricing[1].totalProratedAmount + pricing[1].downpayment - ); - expect(pricing.length).toBe(2); - }); - - it('Should calculate default pricing for paid up front (variant 1 - $540 per 6 months)', async () => { - const { stripeSubscriptionPricing } = await shopClient.query( - GET_PRICING, - { - input: { - productVariantId: 1, - }, - } - ); - const pricing: StripeSubscriptionPricing = stripeSubscriptionPricing; - expect(pricing.downpayment).toBe(0); - expect(pricing.recurringPrice).toBe(54000); - expect(pricing.interval).toBe('month'); - expect(pricing.intervalCount).toBe(6); - expect(pricing.dayRate).toBe(296); - expect(pricing.amountDueNow).toBe(pricing.totalProratedAmount + 54000); - }); - - it('Should calculate pricing for fixed start date', async () => { - const future = new Date('01-01-2099'); - const variant: VariantForCalculation = { - id: 'fixed', - listPrice: 6000, - customFields: { - subscriptionSchedule: new Schedule({ - name: 'Monthly, fixed start date', - durationInterval: SubscriptionInterval.Month, - durationCount: 6, - billingInterval: SubscriptionInterval.Month, - billingCount: 1, - startMoment: SubscriptionStartMoment.FixedStartdate, - fixedStartDate: future, - downpayment: 6000, - useProration: false, - autoRenew: false, - }), - }, - }; - const pricing = calculateSubscriptionPricing( - ctx as any, - variant.listPrice, - variant.customFields.subscriptionSchedule! - ); - expect(pricing.subscriptionStartDate).toBe(future); - expect(pricing.recurringPrice).toBe(6000); - expect(pricing.dayRate).toBe(230); - expect(pricing.amountDueNow).toBe(6000); - expect(pricing.proratedDays).toBe(0); - expect(pricing.totalProratedAmount).toBe(0); - expect(pricing.subscriptionEndDate).toBeDefined(); - }); + it('Created subscriptions', async () => { + expect(createdSubscriptions.length).toBe(1); + }); - it('Should calculate pricing for time_of_purchase', async () => { - const variant: VariantForCalculation = { - id: 'fixed', - listPrice: 6000, - customFields: { - subscriptionSchedule: new Schedule({ - name: 'Monthly, fixed start date', - durationInterval: SubscriptionInterval.Month, - durationCount: 6, - billingInterval: SubscriptionInterval.Month, - billingCount: 1, - startMoment: SubscriptionStartMoment.TimeOfPurchase, - downpayment: 6000, - useProration: true, - }), - }, - }; - const pricing = calculateSubscriptionPricing( - ctx as any, - variant.listPrice, - variant.customFields.subscriptionSchedule! - ); - const now = new Date().toISOString().split('T')[0]; - const subscriptionStartDate = pricing.subscriptionStartDate - .toISOString() - .split('T')[0]; - // compare dates without time - expect(subscriptionStartDate).toBe(now); - expect(pricing.recurringPrice).toBe(6000); - expect(pricing.dayRate).toBe(230); - expect(pricing.amountDueNow).toBe(6000); - expect(pricing.proratedDays).toBe(0); - expect(pricing.totalProratedAmount).toBe(0); + it('Saved subscriptionIds on order line', async () => { + const ctx = await getDefaultCtx(server); + const internalOrder = await server.app.get(OrderService).findOne(ctx, 1); + const subscriptionIds: string[] = []; + internalOrder?.lines.forEach((line) => { + if (line.customFields.subscriptionIds) { + subscriptionIds.push(...line.customFields.subscriptionIds); + } }); + expect(subscriptionIds.length).toBe(1); }); - describe('Subscription order placement', () => { - it('Should create "10% on all subscriptions" promotion', async () => { - await adminClient.asSuperAdmin(); - const promotion = await createPromotion( - adminClient, - 'gimme10', - allByPercentage.code, - [ + it('Should cancel subscription', async () => { + // Mock API + let subscriptionRequests: any[] = []; + nock('https://api.stripe.com') + .post(/subscriptions*/, (body) => { + subscriptionRequests.push(body); + return true; + }) + .reply(200, {}); + await adminClient.query(CANCEL_ORDER, { + input: { + lines: [ { - name: 'discount', - value: '10', + orderLineId: 'T_1', + quantity: 1, }, - ] - ); - expect(promotion.name).toBe('gimme10'); - expect(promotion.couponCode).toBe('gimme10'); - }); - - it('Should create payment intent for order with 2 subscriptions', async () => { - // Mock API - let paymentIntentInput: any = {}; - nock('https://api.stripe.com') - .get(/customers.*/) - .reply(200, { data: [{ id: 'customer-test-id' }] }); - nock('https://api.stripe.com') - .post(/payment_intents.*/, (body) => { - paymentIntentInput = body; - return true; - }) - .reply(200, { - client_secret: 'mock-secret-1234', - }); - await shopClient.asUserWithCredentials( - 'hayden.zieme12@hotmail.com', - 'test' - ); - await shopClient.query(ADD_ITEM_TO_ORDER, { - productVariantId: '1', - quantity: 1, - }); - await shopClient.query(ADD_ITEM_TO_ORDER, { - productVariantId: '2', - quantity: 1, - }); - await shopClient.query(ADD_ITEM_TO_ORDER, { - productVariantId: '3', - quantity: 1, - }); - const { activeOrder } = await shopClient.query(GET_ORDER_WITH_PRICING); - order = activeOrder; - await setShipping(shopClient); - const { createStripeSubscriptionIntent: secret } = await shopClient.query( - CREATE_PAYMENT_LINK - ); - expect(secret).toBe('mock-secret-1234'); - expect(paymentIntentInput.setup_future_usage).toBe('off_session'); - expect(paymentIntentInput.customer).toBe('customer-test-id'); - const weeklyDownpayment = 19900; - const paidInFullTotal = 54000; - const nonSubPrice = 12300; - const minimumPrice = paidInFullTotal + weeklyDownpayment + nonSubPrice; - // Should be greater then or equal, because we can have proration, which is dynamic - expect(parseInt(paymentIntentInput.amount)).toBeGreaterThanOrEqual( - minimumPrice - ); - }); - - it('subscriptionPricing should be available in admin api', async () => { - const { order: activeOrder } = await adminClient.query( - gql` - query GetOrder($id: ID!) { - order(id: $id) { - lines { - subscriptionPricing { - recurringPrice - } - } - } - } - `, - { id: order!.id } - ); - expect(activeOrder.lines[0].subscriptionPricing.recurringPrice).toBe( - 54000 - ); - }); - - it('Should have pricing and schedule on order line', async () => { - const { activeOrder } = await shopClient.query(GET_ORDER_WITH_PRICING); - const line1: OrderLineWithSubscriptionFields = activeOrder.lines[0]; - const line2: OrderLineWithSubscriptionFields = activeOrder.lines[1]; - expect(line1.subscriptionPricing?.recurringPrice).toBe(54000); - expect(line1.subscriptionPricing?.originalRecurringPrice).toBe(54000); - expect(line2.subscriptionPricing?.recurringPrice).toBe(9000); - expect(line2.subscriptionPricing?.originalRecurringPrice).toBe(9000); - expect(line2.subscriptionPricing?.schedule).toBeDefined(); - expect(line2.subscriptionPricing?.schedule.name).toBeDefined(); - expect(line2.subscriptionPricing?.schedule.downpayment).toBe(19900); - expect(line2.subscriptionPricing?.schedule.durationInterval).toBe( - 'month' - ); - expect(line2.subscriptionPricing?.schedule.durationCount).toBe(3); - expect(line2.subscriptionPricing?.schedule.billingInterval).toBe('week'); - expect(line2.subscriptionPricing?.schedule.billingCount).toBe(1); - expect(line2.subscriptionPricing?.schedule.paidUpFront).toBe(false); - }); - - it('Should have discounted pricing on all order lines after applying coupon', async () => { - await applyCouponCode(shopClient, 'gimme10'); - const { activeOrder } = await shopClient.query(GET_ORDER_WITH_PRICING); - const line1: OrderLineWithSubscriptionFields = activeOrder.lines[0]; - const line2: OrderLineWithSubscriptionFields = activeOrder.lines[1]; - expect(line1.subscriptionPricing?.recurringPrice).toBe(48600); - expect(line1.subscriptionPricing?.originalRecurringPrice).toBe(54000); - expect(line2.subscriptionPricing?.recurringPrice).toBe(8100); - expect(line2.subscriptionPricing?.originalRecurringPrice).toBe(9000); - }); - - let createdSubscriptions: any[] = []; - it('Should create subscriptions on webhook succeed', async () => { - // Mock API - nock('https://api.stripe.com') - .get(/customers.*/) - .reply(200, { data: [{ id: 'customer-test-id' }] }); - nock('https://api.stripe.com') - .post(/products.*/) - .reply(200, { - id: 'test-product', - }) - .persist(true); - nock('https://api.stripe.com') - .post(/subscriptions.*/, (body) => { - createdSubscriptions.push(body); - return true; - }) - .times(3) - .reply(200, { - id: 'mock-sub', - status: 'active', - }); - let adminOrder = await getOrder(adminClient as any, order!.id as string); - await adminClient.fetch( - 'http://localhost:3050/stripe-subscriptions/webhook', - { - method: 'POST', - body: JSON.stringify({ - type: 'payment_intent.succeeded', - data: { - object: { - customer: 'mock', - metadata: { - orderCode: order!.code, - paymentMethodCode: 'stripe-subscription-method', - channelToken: 'e2e-default-channel', - amount: adminOrder?.totalWithTax, - }, - }, - }, - } as IncomingStripeWebhook), - } - ); - await new Promise((resolve) => setTimeout(resolve, 2000)); - adminOrder = await getOrder(adminClient as any, order!.id as string); - expect(adminOrder?.state).toBe('PaymentSettled'); - // Expect 3 subs: paidInFull, weekly and downpayment - expect(createdSubscriptions.length).toBe(3); - const ctx = await getDefaultCtx(server); - const internalOrder = await server.app.get(OrderService).findOne(ctx, 1); - const subscriptionIds: string[] = []; - internalOrder?.lines.forEach((line: OrderLineWithSubscriptionFields) => { - if (line.customFields.subscriptionIds) { - subscriptionIds.push(...line.customFields.subscriptionIds); - } - }); - // Expect 3 saved stripe ID's - expect(subscriptionIds.length).toBe(3); - }); - - it('Created paid in full subscription', async () => { - const paidInFull = createdSubscriptions.find( - (s) => s.description === 'Adult karate Paid in full' - ); - expect(paidInFull?.customer).toBe('mock'); - expect(paidInFull?.proration_behavior).toBe('none'); - expect(paidInFull?.['items[0][price_data][unit_amount]']).toBe('48600'); // discounted price - expect(paidInFull?.['items[0][price_data][recurring][interval]']).toBe( - 'month' - ); - expect( - paidInFull?.['items[0][price_data][recurring][interval_count]'] - ).toBe('6'); - const in5months = new Date(); - in5months.setMonth(in5months.getMonth() + 5); - // Trial-end should be after atleast 5 months - expect(parseInt(paidInFull?.trial_end)).toBeGreaterThan( - in5months.getTime() / 1000 - ); - }); - - it('Created weekly subscription', async () => { - const weeklySub = createdSubscriptions.find( - (s) => s.description === 'Adult karate Recurring' - ); - expect(weeklySub?.customer).toBe('mock'); - expect(weeklySub?.proration_behavior).toBe('none'); - expect(weeklySub?.['items[0][price_data][unit_amount]']).toBe('8100'); // Discounted price - expect(weeklySub?.['items[0][price_data][recurring][interval]']).toBe( - 'week' - ); - expect( - weeklySub?.['items[0][price_data][recurring][interval_count]'] - ).toBe('1'); - const in7days = new Date(); - in7days.setDate(in7days.getDate() + 7); - // Trial-end (startDate) should somewhere within the next 7 days - expect(parseInt(weeklySub?.trial_end)).toBeLessThan( - in7days.getTime() / 1000 - ); - const in3Months = new Date(); - in3Months.setMonth(in3Months.getMonth() + 3); - // No autorenew, so cancel_at should be in ~3 months - expect(parseInt(weeklySub?.cancel_at)).toBeGreaterThan( - in3Months.getTime() / 1000 - ); - }); - - it('Created downpayment subscription that renews 3 months from now', async () => { - const downpaymentRequest = createdSubscriptions.find( - (s) => s.description === 'Downpayment' - ); - expect(downpaymentRequest?.customer).toBe('mock'); - expect(downpaymentRequest?.proration_behavior).toBe('none'); - expect(downpaymentRequest?.['items[0][price_data][unit_amount]']).toBe( - '19900' - ); - expect( - downpaymentRequest?.['items[0][price_data][recurring][interval]'] - ).toBe('month'); - expect( - downpaymentRequest?.['items[0][price_data][recurring][interval_count]'] - ).toBe('3'); - const in2Months = new Date(); - in2Months.setMonth(in2Months.getMonth() + 2); // Atleast 2 months in between (can also be 2 months and 29 days) - // Downpayment should renew after duration (At least 2 months) - expect(parseInt(downpaymentRequest?.trial_end)).toBeGreaterThan( - in2Months.getTime() / 1000 - ); - }); - - it('Logs payments to order history', async () => { - const result = await adminClient.fetch( - 'http://localhost:3050/stripe-subscriptions/webhook', - { - method: 'POST', - body: JSON.stringify({ - type: 'invoice.payment_failed', - data: { - object: { - customer: 'mock', - lines: { - data: [ - { - metadata: { - orderCode: order!.code, - channelToken: 'e2e-default-channel', - }, - }, - ], - }, - }, - }, - } as IncomingStripeWebhook), - } - ); - const ctx = await getDefaultCtx(server); - const history = await server.app - .get(HistoryService) - .getHistoryForOrder(ctx, 1, false); - expect(result.status).toBe(201); - expect( - history.items.find( - (item) => item.data.message === 'Subscription payment failed' - ) - ).toBeDefined(); - }); - - it('Should save payment event', async () => { - const ctx = await getDefaultCtx(server); - const paymentEvents = await server.app - .get(StripeSubscriptionService) - .getPaymentEvents(ctx, {}); - expect(paymentEvents.items?.length).toBeGreaterThan(0); - }); - - it('Should cancel subscription', async () => { - // Mock API - let subscriptionRequests: any[] = []; - nock('https://api.stripe.com') - .post(/subscriptions*/, (body) => { - subscriptionRequests.push(body); - return true; - }) - .reply(200, {}); - await adminClient.query(REMOVE_ORDERLINE, { - input: { - lines: [ - { - orderLineId: 'T_1', - quantity: 1, - }, - ], - orderId: 'T_1', - reason: 'Customer request', - cancelShipping: false, - }, - }); - await new Promise((resolve) => setTimeout(resolve, 1000)); // Await worker processing - expect(subscriptionRequests[0].cancel_at_period_end).toBe('true'); - }); - - it('Should refund subscription', async () => { - // Mock API - let refundRequests: any = []; - nock('https://api.stripe.com') - .post(/refunds*/, (body) => { - refundRequests.push(body); - return true; - }) - .reply(200, {}); - await adminClient.query(REFUND_ORDER, { - input: { - lines: [ - { - orderLineId: 'T_1', - quantity: 1, - }, - ], - reason: 'Customer request', - shipping: 0, - adjustment: 0, - paymentId: 'T_1', - }, - }); - expect(refundRequests[0].amount).toBeDefined(); - }); - - it(`All OrderEvents have ctx.req`, () => { - expect.hasAssertions(); - orderEvents.forEach((event) => { - expect(event.ctx.req).toBeDefined(); - }); - }); - - it('Should fail to create payment intent for ineligible order', async () => { - await shopClient.asUserWithCredentials( - 'trevor_donnelly96@hotmail.com', - 'test' - ); - // Variant 3 is not a subscription product, so the eligiblity checker should not allow intent creation - await shopClient.query(ADD_ITEM_TO_ORDER, { - productVariantId: '3', - quantity: 1, - }); - await setShipping(shopClient); - let error: any; - await shopClient.query(CREATE_PAYMENT_LINK).catch((e) => (error = e)); - expect(error?.message).toBe( - `No eligible payment method found with code 'stripe-subscription'` - ); + ], + orderId: 'T_1', + reason: 'Customer request', + cancelShipping: false, + }, }); + await new Promise((resolve) => setTimeout(resolve, 500)); // Await worker processing + expect(subscriptionRequests[0].cancel_at_period_end).toBe('true'); }); - describe('Schedule management', () => { - it('Creates a fixed-date schedule', async () => { - const now = new Date().toISOString(); - const { upsertStripeSubscriptionSchedule: schedule } = - await adminClient.query(UPSERT_SCHEDULES, { - input: { - name: '3 months, billed weekly, fixed date', - downpayment: 0, - durationInterval: SubscriptionInterval.Month, - durationCount: 3, - startMoment: SubscriptionStartMoment.FixedStartdate, - fixedStartDate: now, - billingInterval: SubscriptionInterval.Week, - billingCount: 1, - }, - }); - expect(schedule.startMoment).toBe(SubscriptionStartMoment.FixedStartdate); - expect(schedule.fixedStartDate).toBe(now); - }); - - it('Can retrieve Schedules', async () => { - await adminClient.asSuperAdmin(); - const { stripeSubscriptionSchedules: schedules } = - await adminClient.query(GET_SCHEDULES); - expect(schedules.items[0]).toBeDefined(); - expect(schedules.items[0].id).toBeDefined(); - }); - - it('Can delete Schedules', async () => { - await adminClient.asSuperAdmin(); - const { upsertStripeSubscriptionSchedule: toBeDeleted } = - await adminClient.query(UPSERT_SCHEDULES, { - input: { - name: '6 months, paid in full', - downpayment: 0, - durationInterval: SubscriptionInterval.Month, - durationCount: 6, - startMoment: SubscriptionStartMoment.StartOfBillingInterval, - billingInterval: SubscriptionInterval.Month, - billingCount: 6, + it('Should refund subscription', async () => { + // Mock API + let refundRequests: any = []; + nock('https://api.stripe.com') + .post(/refunds*/, (body) => { + refundRequests.push(body); + return true; + }) + .reply(200, {}); + await adminClient.query(REFUND_ORDER, { + input: { + lines: [ + { + orderLineId: 'T_1', + quantity: 1, }, - }); - await adminClient.query(DELETE_SCHEDULE, { scheduleId: toBeDeleted.id }); - const { stripeSubscriptionSchedules: schedules } = - await adminClient.query(GET_SCHEDULES); - expect( - schedules.items.find((s: any) => s.id == toBeDeleted.id) - ).toBeUndefined(); - }); - - it('Fails to create fixed-date without start date', async () => { - expect.assertions(1); - const promise = adminClient.query(UPSERT_SCHEDULES, { - input: { - name: '3 months, billed weekly, fixed date', - downpayment: 0, - durationInterval: SubscriptionInterval.Month, - durationCount: 3, - startMoment: SubscriptionStartMoment.FixedStartdate, - billingInterval: SubscriptionInterval.Week, - billingCount: 1, - }, - }); - await expect(promise).rejects.toThrow(); - }); - - it('Fails to create paid-up-front with downpayment', async () => { - expect.assertions(1); - const promise = adminClient.query(UPSERT_SCHEDULES, { - input: { - name: 'Paid up front, $199 downpayment', - downpayment: 19900, - durationInterval: SubscriptionInterval.Month, - durationCount: 6, - startMoment: SubscriptionStartMoment.StartOfBillingInterval, - billingInterval: SubscriptionInterval.Month, - billingCount: 6, - }, - }); - await expect(promise).rejects.toThrow(); + ], + reason: 'Customer request', + shipping: 0, + adjustment: 0, + paymentId: 'T_1', + }, }); + expect(refundRequests[0].amount).toBeDefined(); }); - describe('Publishable key', () => { - it('Should expose publishable key via shop api', async () => { - const { eligiblePaymentMethods } = await shopClient.query( - ELIGIBLE_PAYMENT_METHODS - ); - expect(eligiblePaymentMethods[0].stripeSubscriptionPublishableKey).toBe( - 'test-publishable-key' - ); + it(`Published all OrderEvents with a ctx.req`, () => { + expect.hasAssertions(); + orderEvents.forEach((event) => { + expect(event.ctx.req).toBeDefined(); }); }); }); diff --git a/packages/vendure-plugin-stripe-subscription/test/stripe-subscription.spec.ts.backup.ts b/packages/vendure-plugin-stripe-subscription/test/stripe-subscription.spec.ts.backup.ts new file mode 100644 index 00000000..35674580 --- /dev/null +++ b/packages/vendure-plugin-stripe-subscription/test/stripe-subscription.spec.ts.backup.ts @@ -0,0 +1,1074 @@ +import { + DefaultLogger, + EventBus, + HistoryService, + LogLevel, + mergeConfig, + Order, + OrderPlacedEvent, + OrderService, + OrderStateTransitionEvent, +} from '@vendure/core'; +import { + createTestEnvironment, + registerInitializer, + SimpleGraphQLClient, + SqljsInitializer, + testConfig, +} from '@vendure/testing'; +import { TestServer } from '@vendure/testing/lib/test-server'; +// @ts-ignore +import nock from 'nock'; +// @ts-ignore +import { createPromotion, getOrder } from '../../test/src/admin-utils'; +import { initialData } from '../../test/src/initial-data'; +import { applyCouponCode } from '../../test/src/shop-utils'; +import { + allByPercentage, + calculateSubscriptionPricing, + getBillingsPerDuration, + getDayRate, + getDaysUntilNextStartDate, + getNextCyclesStartDate, + getNextStartDate, + IncomingStripeWebhook, + OrderLineWithSubscriptionFields, + Schedule, + StripeSubscriptionPlugin, + StripeSubscriptionPricing, + StripeSubscriptionService, + SubscriptionInterval, + SubscriptionStartMoment, + VariantForCalculation, +} from '../src'; +import { DELETE_SCHEDULE, UPSERT_SCHEDULES } from '../src/ui/queries'; +import { + ADD_ITEM_TO_ORDER, + CREATE_PAYMENT_LINK, + CREATE_PAYMENT_METHOD, + GET_ORDER_WITH_PRICING, + GET_PRICING, + GET_PRICING_FOR_PRODUCT, + GET_SCHEDULES, + getDefaultCtx, + REFUND_ORDER, + REMOVE_ORDERLINE, + setShipping, + UPDATE_CHANNEL, + UPDATE_VARIANT, + ELIGIBLE_PAYMENT_METHODS, +} from './helpers'; +import { expect, describe, beforeAll, afterAll, it, vi, test } from 'vitest'; +import { gql } from 'graphql-tag'; + +describe('Stripe Subscription Plugin', function () { + let server: TestServer; + let adminClient: SimpleGraphQLClient; + let shopClient: SimpleGraphQLClient; + let serverStarted = false; + let order: Order | undefined; + let orderEvents: (OrderStateTransitionEvent | OrderPlacedEvent)[] = []; + + beforeAll(async () => { + registerInitializer('sqljs', new SqljsInitializer('__data__')); + const config = mergeConfig(testConfig, { + logger: new DefaultLogger({ level: LogLevel.Debug }), + plugins: [ + StripeSubscriptionPlugin.init({ + disableWebhookSignatureChecking: true, + }), + ], + }); + ({ server, adminClient, shopClient } = createTestEnvironment(config)); + await server.init({ + initialData, + productsCsvPath: `${__dirname}/subscriptions.csv`, + }); + serverStarted = true; + }, 60000); + + it('Should start successfully', async () => { + expect(serverStarted).toBe(true); + }); + + it('Listens for OrderPlacedEvent and OrderStateTransitionEvents', async () => { + server.app + .get(EventBus) + .ofType(OrderPlacedEvent) + .subscribe((event) => { + orderEvents.push(event); + }); + server.app + .get(EventBus) + .ofType(OrderStateTransitionEvent) + .subscribe((event) => { + orderEvents.push(event); + }); + }); + + const ctx = { channel: { pricesIncludeTax: true } }; + + it("Sets channel settings to 'prices are including tax'", async () => { + await adminClient.asSuperAdmin(); + const { + updateChannel: { id }, + } = await adminClient.query(UPDATE_CHANNEL, { + input: { + id: 'T_1', + pricesIncludeTax: true, + }, + }); + expect(id).toBe('T_1'); + }); + + it('Creates Stripe subscription method', async () => { + await adminClient.asSuperAdmin(); + await adminClient.query(CREATE_PAYMENT_METHOD, { + input: { + translations: [ + { + languageCode: 'en', + name: 'Stripe test payment', + description: 'This is a Stripe payment method', + }, + ], + code: 'stripe-subscription-method', + enabled: true, + checker: { + code: 'has-stripe-subscription-products-checker', + arguments: [], + }, + handler: { + code: 'stripe-subscription', + arguments: [ + { + name: 'webhookSecret', + value: 'testsecret', + }, + { name: 'apiKey', value: 'test-api-key' }, + { name: 'publishableKey', value: 'test-publishable-key' }, + ], + }, + }, + }); + }); + + describe('Get next cycles start date', () => { + test.each([ + [ + 'Start of the month, in 12 months', + new Date('2022-12-20T12:00:00.000Z'), + SubscriptionStartMoment.StartOfBillingInterval, + 12, + SubscriptionInterval.Month, + '2024-01-01', + ], + [ + 'End of the month, in 6 months', + new Date('2022-12-20T12:00:00.000Z'), + SubscriptionStartMoment.EndOfBillingInterval, + 6, + SubscriptionInterval.Month, + '2023-06-30', + ], + [ + 'Time of purchase, in 8 weeks', + new Date('2022-12-20'), + SubscriptionStartMoment.TimeOfPurchase, + 8, + SubscriptionInterval.Week, + '2023-02-14', + ], + ])( + 'Calculates next cycles start date for "%s"', + ( + _message: string, + now: Date, + startDate: SubscriptionStartMoment, + intervalCount: number, + interval: SubscriptionInterval, + expected: string + ) => { + expect( + getNextCyclesStartDate(now, startDate, interval, intervalCount) + .toISOString() + .split('T')[0] + ).toEqual(expected); + } + ); + }); + + describe('Calculate day rate', () => { + test.each([ + [20000, 6, SubscriptionInterval.Month, 110], + [80000, 24, SubscriptionInterval.Month, 110], + [20000, 26, SubscriptionInterval.Week, 110], + [40000, 52, SubscriptionInterval.Week, 110], + ])( + 'Day rate for $%i per %i %s should be $%i', + ( + price: number, + count: number, + interval: SubscriptionInterval, + expected: number + ) => { + expect(getDayRate(price, interval, count)).toBe(expected); + } + ); + }); + + describe('Calculate billings per duration', () => { + test.each([ + [SubscriptionInterval.Week, 1, SubscriptionInterval.Month, 3, 12], + [SubscriptionInterval.Week, 2, SubscriptionInterval.Month, 1, 2], + [SubscriptionInterval.Week, 3, SubscriptionInterval.Month, 3, 4], + [SubscriptionInterval.Month, 3, SubscriptionInterval.Month, 6, 2], + ])( + 'for %sly %s', + ( + billingInterval: SubscriptionInterval, + billingCount: number, + durationInterval: SubscriptionInterval, + durationCount: number, + expected: number + ) => { + expect( + getBillingsPerDuration({ + billingInterval, + billingCount, + durationCount, + durationInterval, + }) + ).toBe(expected); + } + ); + }); + + describe('Calculate nr of days until next subscription start date', () => { + test.each([ + [ + new Date('2022-12-20'), + SubscriptionStartMoment.StartOfBillingInterval, + SubscriptionInterval.Month, + undefined, + 12, + ], + [ + new Date('2022-12-20'), + SubscriptionStartMoment.EndOfBillingInterval, + SubscriptionInterval.Month, + undefined, + 11, + ], + [ + new Date('2022-12-20'), + SubscriptionStartMoment.StartOfBillingInterval, + SubscriptionInterval.Week, + undefined, + 5, + ], + [ + new Date('2022-12-20'), + SubscriptionStartMoment.EndOfBillingInterval, + SubscriptionInterval.Week, + undefined, + 4, + ], + [ + new Date('2022-12-20'), + SubscriptionStartMoment.TimeOfPurchase, + SubscriptionInterval.Week, + undefined, + 0, + ], + [ + new Date('2022-12-20'), + SubscriptionStartMoment.FixedStartdate, + SubscriptionInterval.Week, + new Date('2022-12-22'), + 2, + ], + ])( + 'Calculate days: from %s to "%s" of %s should be %i', + ( + now: Date, + startDate: SubscriptionStartMoment, + interval: SubscriptionInterval, + fixedStartDate: Date | undefined, + expected: number + ) => { + const nextStartDate = getNextStartDate( + now, + interval, + startDate, + fixedStartDate + ); + expect(getDaysUntilNextStartDate(now, nextStartDate)).toBe(expected); + } + ); + }); + + it('Creates a paid-up-front subscription for variant 1 ($540)', async () => { + const { upsertStripeSubscriptionSchedule: schedule } = + await adminClient.query(UPSERT_SCHEDULES, { + input: { + name: '6 months, paid in full', + downpayment: 0, + durationInterval: SubscriptionInterval.Month, + durationCount: 6, + startMoment: SubscriptionStartMoment.StartOfBillingInterval, + billingInterval: SubscriptionInterval.Month, + billingCount: 6, + useProration: true, + autoRenew: true, + }, + }); + const { + updateProductVariants: [variant], + } = await adminClient.query(UPDATE_VARIANT, { + input: [ + { + id: 1, + customFields: { + subscriptionScheduleId: schedule.id, + }, + }, + ], + }); + expect(schedule.id).toBeDefined(); + expect(schedule.createdAt).toBeDefined(); + expect(schedule.name).toBe('6 months, paid in full'); + expect(schedule.downpayment).toBe(0); + expect(schedule.paidUpFront).toBe(true); + expect(schedule.durationInterval).toBe(schedule.billingInterval); // duration and billing should be equal for paid up front subs + expect(schedule.durationCount).toBe(schedule.billingCount); + expect(schedule.startMoment).toBe( + SubscriptionStartMoment.StartOfBillingInterval + ); + expect(schedule.useProration).toBe(true); + expect(schedule.useProration).toBe(true); + expect(variant.id).toBe(schedule.id); + }); + + it('Creates a 3 month, billed weekly subscription for variant 2 ($90)', async () => { + const { upsertStripeSubscriptionSchedule: schedule } = + await adminClient.query(UPSERT_SCHEDULES, { + input: { + name: '6 months, billed monthly, 199 downpayment', + downpayment: 19900, + durationInterval: SubscriptionInterval.Month, + durationCount: 3, + startMoment: SubscriptionStartMoment.StartOfBillingInterval, + billingInterval: SubscriptionInterval.Week, + billingCount: 1, + autoRenew: false, + }, + }); + const { + updateProductVariants: [variant], + } = await adminClient.query(UPDATE_VARIANT, { + input: [ + { + id: 2, + customFields: { + subscriptionScheduleId: schedule.id, + }, + }, + ], + }); + expect(schedule.id).toBeDefined(); + expect(schedule.createdAt).toBeDefined(); + expect(schedule.name).toBe('6 months, billed monthly, 199 downpayment'); + expect(schedule.downpayment).toBe(19900); + expect(schedule.durationInterval).toBe(SubscriptionInterval.Month); + expect(schedule.durationCount).toBe(3); + expect(schedule.billingInterval).toBe(SubscriptionInterval.Week); + expect(schedule.billingCount).toBe(1); + expect(schedule.startMoment).toBe( + SubscriptionStartMoment.StartOfBillingInterval + ); + expect(variant.id).toBe(schedule.id); + }); + + describe('Pricing calculations', () => { + it('Should calculate default pricing for recurring subscription (variant 2 - $90 per week)', async () => { + // Uses the default downpayment of $199 + const { stripeSubscriptionPricing } = await shopClient.query( + GET_PRICING, + { + input: { + productVariantId: 2, + }, + } + ); + const pricing: StripeSubscriptionPricing = stripeSubscriptionPricing; + expect(pricing.downpayment).toBe(19900); + expect(pricing.recurringPrice).toBe(9000); + expect(pricing.interval).toBe('week'); + expect(pricing.intervalCount).toBe(1); + expect(pricing.dayRate).toBe(1402); + expect(pricing.amountDueNow).toBe(pricing.totalProratedAmount + 19900); + expect(pricing.schedule.name).toBe( + '6 months, billed monthly, 199 downpayment' + ); + }); + + it('Should calculate pricing for recurring with $400 custom downpayment', async () => { + const { stripeSubscriptionPricing } = await shopClient.query( + GET_PRICING, + { + input: { + productVariantId: 2, + downpayment: 40000, + }, + } + ); + // Default price is $90 a month with a downpayment of $199 + // With a downpayment of $400, the price should be ($400 - $199) / 12 = $16.75 lower, so $73,25 + const pricing: StripeSubscriptionPricing = stripeSubscriptionPricing; + expect(pricing.downpayment).toBe(40000); + expect(pricing.recurringPrice).toBe(7325); + expect(pricing.interval).toBe('week'); + expect(pricing.intervalCount).toBe(1); + expect(pricing.dayRate).toBe(1402); + expect(pricing.amountDueNow).toBe(pricing.totalProratedAmount + 40000); + }); + + it('Should throw an error when downpayment is below the schedules default', async () => { + let error = ''; + await shopClient + .query(GET_PRICING, { + input: { + productVariantId: 2, + downpayment: 0, + }, + }) + .catch((e) => (error = e.message)); + expect(error).toContain('Downpayment cannot be lower than'); + }); + + it('Should throw an error when downpayment is higher than the total subscription value', async () => { + let error = ''; + await shopClient + .query(GET_PRICING, { + input: { + productVariantId: 2, + downpayment: 990000, // max is 1080 + 199 = 1279 + }, + }) + .catch((e) => (error = e.message)); + expect(error).toContain('Downpayment cannot be higher than'); + }); + + it('Should throw error when trying to use a downpayment for paid up front', async () => { + let error = ''; + await shopClient + .query(GET_PRICING, { + input: { + productVariantId: 1, + downpayment: 19900, // max is 540 + 199 = 739 + }, + }) + .catch((e) => (error = e.message)); + expect(error).toContain( + 'You can not use downpayments with Paid-up-front subscriptions' + ); + }); + + it('Should calculate pricing for recurring with custom downpayment and custom startDate', async () => { + // Uses the default downpayment of $199 + const in3Days = new Date(); + in3Days.setDate(in3Days.getDate() + 3); + const { stripeSubscriptionPricing } = await shopClient.query( + GET_PRICING, + { + input: { + productVariantId: 2, + downpayment: 40000, + startDate: in3Days.toISOString(), + }, + } + ); + const pricing: StripeSubscriptionPricing = stripeSubscriptionPricing; + expect(pricing.downpayment).toBe(40000); + expect(pricing.recurringPrice).toBe(7325); + expect(pricing.interval).toBe('week'); + expect(pricing.intervalCount).toBe(1); + expect(pricing.dayRate).toBe(1402); + expect(pricing.amountDueNow).toBe(pricing.totalProratedAmount + 40000); + }); + + it('Should calculate pricing for each variant of product', async () => { + const { stripeSubscriptionPricingForProduct } = await shopClient.query( + GET_PRICING_FOR_PRODUCT, + { + productId: 1, + } + ); + const pricing: StripeSubscriptionPricing[] = + stripeSubscriptionPricingForProduct; + expect(pricing[1].downpayment).toBe(19900); + expect(pricing[1].recurringPrice).toBe(9000); + expect(pricing[1].interval).toBe('week'); + expect(pricing[1].intervalCount).toBe(1); + expect(pricing[1].dayRate).toBe(1402); + expect(pricing[1].amountDueNow).toBe( + pricing[1].totalProratedAmount + pricing[1].downpayment + ); + expect(pricing.length).toBe(2); + }); + + it('Should calculate default pricing for paid up front (variant 1 - $540 per 6 months)', async () => { + const { stripeSubscriptionPricing } = await shopClient.query( + GET_PRICING, + { + input: { + productVariantId: 1, + }, + } + ); + const pricing: StripeSubscriptionPricing = stripeSubscriptionPricing; + expect(pricing.downpayment).toBe(0); + expect(pricing.recurringPrice).toBe(54000); + expect(pricing.interval).toBe('month'); + expect(pricing.intervalCount).toBe(6); + expect(pricing.dayRate).toBe(296); + expect(pricing.amountDueNow).toBe(pricing.totalProratedAmount + 54000); + }); + + it('Should calculate pricing for fixed start date', async () => { + const future = new Date('01-01-2099'); + const variant: VariantForCalculation = { + id: 'fixed', + listPrice: 6000, + customFields: { + subscriptionSchedule: new Schedule({ + name: 'Monthly, fixed start date', + durationInterval: SubscriptionInterval.Month, + durationCount: 6, + billingInterval: SubscriptionInterval.Month, + billingCount: 1, + startMoment: SubscriptionStartMoment.FixedStartdate, + fixedStartDate: future, + downpayment: 6000, + useProration: false, + autoRenew: false, + }), + }, + }; + const pricing = calculateSubscriptionPricing( + ctx as any, + variant.listPrice, + variant.customFields.subscriptionSchedule! + ); + expect(pricing.subscriptionStartDate).toBe(future); + expect(pricing.recurringPrice).toBe(6000); + expect(pricing.dayRate).toBe(230); + expect(pricing.amountDueNow).toBe(6000); + expect(pricing.proratedDays).toBe(0); + expect(pricing.totalProratedAmount).toBe(0); + expect(pricing.subscriptionEndDate).toBeDefined(); + }); + + it('Should calculate pricing for time_of_purchase', async () => { + const variant: VariantForCalculation = { + id: 'fixed', + listPrice: 6000, + customFields: { + subscriptionSchedule: new Schedule({ + name: 'Monthly, fixed start date', + durationInterval: SubscriptionInterval.Month, + durationCount: 6, + billingInterval: SubscriptionInterval.Month, + billingCount: 1, + startMoment: SubscriptionStartMoment.TimeOfPurchase, + downpayment: 6000, + useProration: true, + }), + }, + }; + const pricing = calculateSubscriptionPricing( + ctx as any, + variant.listPrice, + variant.customFields.subscriptionSchedule! + ); + const now = new Date().toISOString().split('T')[0]; + const subscriptionStartDate = pricing.subscriptionStartDate + .toISOString() + .split('T')[0]; + // compare dates without time + expect(subscriptionStartDate).toBe(now); + expect(pricing.recurringPrice).toBe(6000); + expect(pricing.dayRate).toBe(230); + expect(pricing.amountDueNow).toBe(6000); + expect(pricing.proratedDays).toBe(0); + expect(pricing.totalProratedAmount).toBe(0); + }); + }); + + describe('Subscription order placement', () => { + it('Should create "10% on all subscriptions" promotion', async () => { + await adminClient.asSuperAdmin(); + const promotion = await createPromotion( + adminClient, + 'gimme10', + allByPercentage.code, + [ + { + name: 'discount', + value: '10', + }, + ] + ); + expect(promotion.name).toBe('gimme10'); + expect(promotion.couponCode).toBe('gimme10'); + }); + + it('Should create payment intent for order with 2 subscriptions', async () => { + // Mock API + let paymentIntentInput: any = {}; + nock('https://api.stripe.com') + .get(/customers.*/) + .reply(200, { data: [{ id: 'customer-test-id' }] }); + nock('https://api.stripe.com') + .post(/payment_intents.*/, (body) => { + paymentIntentInput = body; + return true; + }) + .reply(200, { + client_secret: 'mock-secret-1234', + }); + await shopClient.asUserWithCredentials( + 'hayden.zieme12@hotmail.com', + 'test' + ); + await shopClient.query(ADD_ITEM_TO_ORDER, { + productVariantId: '1', + quantity: 1, + }); + await shopClient.query(ADD_ITEM_TO_ORDER, { + productVariantId: '2', + quantity: 1, + }); + await shopClient.query(ADD_ITEM_TO_ORDER, { + productVariantId: '3', + quantity: 1, + }); + const { activeOrder } = await shopClient.query(GET_ORDER_WITH_PRICING); + order = activeOrder; + await setShipping(shopClient); + const { createStripeSubscriptionIntent: secret } = await shopClient.query( + CREATE_PAYMENT_LINK + ); + expect(secret).toBe('mock-secret-1234'); + expect(paymentIntentInput.setup_future_usage).toBe('off_session'); + expect(paymentIntentInput.customer).toBe('customer-test-id'); + const weeklyDownpayment = 19900; + const paidInFullTotal = 54000; + const nonSubPrice = 12300; + const minimumPrice = paidInFullTotal + weeklyDownpayment + nonSubPrice; + // Should be greater then or equal, because we can have proration, which is dynamic + expect(parseInt(paymentIntentInput.amount)).toBeGreaterThanOrEqual( + minimumPrice + ); + }); + + it('subscriptionPricing should be available in admin api', async () => { + const { order: activeOrder } = await adminClient.query( + gql` + query GetOrder($id: ID!) { + order(id: $id) { + lines { + subscriptionPricing { + recurringPrice + } + } + } + } + `, + { id: order!.id } + ); + expect(activeOrder.lines[0].subscriptionPricing.recurringPrice).toBe( + 54000 + ); + }); + + it('Should have pricing and schedule on order line', async () => { + const { activeOrder } = await shopClient.query(GET_ORDER_WITH_PRICING); + const line1: OrderLineWithSubscriptionFields = activeOrder.lines[0]; + const line2: OrderLineWithSubscriptionFields = activeOrder.lines[1]; + expect(line1.subscriptionPricing?.recurringPrice).toBe(54000); + expect(line1.subscriptionPricing?.originalRecurringPrice).toBe(54000); + expect(line2.subscriptionPricing?.recurringPrice).toBe(9000); + expect(line2.subscriptionPricing?.originalRecurringPrice).toBe(9000); + expect(line2.subscriptionPricing?.schedule).toBeDefined(); + expect(line2.subscriptionPricing?.schedule.name).toBeDefined(); + expect(line2.subscriptionPricing?.schedule.downpayment).toBe(19900); + expect(line2.subscriptionPricing?.schedule.durationInterval).toBe( + 'month' + ); + expect(line2.subscriptionPricing?.schedule.durationCount).toBe(3); + expect(line2.subscriptionPricing?.schedule.billingInterval).toBe('week'); + expect(line2.subscriptionPricing?.schedule.billingCount).toBe(1); + expect(line2.subscriptionPricing?.schedule.paidUpFront).toBe(false); + }); + + it('Should have discounted pricing on all order lines after applying coupon', async () => { + await applyCouponCode(shopClient, 'gimme10'); + const { activeOrder } = await shopClient.query(GET_ORDER_WITH_PRICING); + const line1: OrderLineWithSubscriptionFields = activeOrder.lines[0]; + const line2: OrderLineWithSubscriptionFields = activeOrder.lines[1]; + expect(line1.subscriptionPricing?.recurringPrice).toBe(48600); + expect(line1.subscriptionPricing?.originalRecurringPrice).toBe(54000); + expect(line2.subscriptionPricing?.recurringPrice).toBe(8100); + expect(line2.subscriptionPricing?.originalRecurringPrice).toBe(9000); + }); + + let createdSubscriptions: any[] = []; + it('Should create subscriptions on webhook succeed', async () => { + // Mock API + nock('https://api.stripe.com') + .get(/customers.*/) + .reply(200, { data: [{ id: 'customer-test-id' }] }); + nock('https://api.stripe.com') + .post(/products.*/) + .reply(200, { + id: 'test-product', + }) + .persist(true); + nock('https://api.stripe.com') + .post(/subscriptions.*/, (body) => { + createdSubscriptions.push(body); + return true; + }) + .times(3) + .reply(200, { + id: 'mock-sub', + status: 'active', + }); + let adminOrder = await getOrder(adminClient as any, order!.id as string); + await adminClient.fetch( + 'http://localhost:3050/stripe-subscriptions/webhook', + { + method: 'POST', + body: JSON.stringify({ + type: 'payment_intent.succeeded', + data: { + object: { + customer: 'mock', + metadata: { + orderCode: order!.code, + paymentMethodCode: 'stripe-subscription-method', + channelToken: 'e2e-default-channel', + amount: adminOrder?.totalWithTax, + }, + }, + }, + } as IncomingStripeWebhook), + } + ); + await new Promise((resolve) => setTimeout(resolve, 2000)); + adminOrder = await getOrder(adminClient as any, order!.id as string); + expect(adminOrder?.state).toBe('PaymentSettled'); + // Expect 3 subs: paidInFull, weekly and downpayment + expect(createdSubscriptions.length).toBe(3); + const ctx = await getDefaultCtx(server); + const internalOrder = await server.app.get(OrderService).findOne(ctx, 1); + const subscriptionIds: string[] = []; + internalOrder?.lines.forEach((line: OrderLineWithSubscriptionFields) => { + if (line.customFields.subscriptionIds) { + subscriptionIds.push(...line.customFields.subscriptionIds); + } + }); + // Expect 3 saved stripe ID's + expect(subscriptionIds.length).toBe(3); + }); + + it('Created paid in full subscription', async () => { + const paidInFull = createdSubscriptions.find( + (s) => s.description === 'Adult karate Paid in full' + ); + expect(paidInFull?.customer).toBe('mock'); + expect(paidInFull?.proration_behavior).toBe('none'); + expect(paidInFull?.['items[0][price_data][unit_amount]']).toBe('48600'); // discounted price + expect(paidInFull?.['items[0][price_data][recurring][interval]']).toBe( + 'month' + ); + expect( + paidInFull?.['items[0][price_data][recurring][interval_count]'] + ).toBe('6'); + const in5months = new Date(); + in5months.setMonth(in5months.getMonth() + 5); + // Trial-end should be after atleast 5 months + expect(parseInt(paidInFull?.trial_end)).toBeGreaterThan( + in5months.getTime() / 1000 + ); + }); + + it('Created weekly subscription', async () => { + const weeklySub = createdSubscriptions.find( + (s) => s.description === 'Adult karate Recurring' + ); + expect(weeklySub?.customer).toBe('mock'); + expect(weeklySub?.proration_behavior).toBe('none'); + expect(weeklySub?.['items[0][price_data][unit_amount]']).toBe('8100'); // Discounted price + expect(weeklySub?.['items[0][price_data][recurring][interval]']).toBe( + 'week' + ); + expect( + weeklySub?.['items[0][price_data][recurring][interval_count]'] + ).toBe('1'); + const in7days = new Date(); + in7days.setDate(in7days.getDate() + 7); + // Trial-end (startDate) should somewhere within the next 7 days + expect(parseInt(weeklySub?.trial_end)).toBeLessThan( + in7days.getTime() / 1000 + ); + const in3Months = new Date(); + in3Months.setMonth(in3Months.getMonth() + 3); + // No autorenew, so cancel_at should be in ~3 months + expect(parseInt(weeklySub?.cancel_at)).toBeGreaterThan( + in3Months.getTime() / 1000 + ); + }); + + it('Created downpayment subscription that renews 3 months from now', async () => { + const downpaymentRequest = createdSubscriptions.find( + (s) => s.description === 'Downpayment' + ); + expect(downpaymentRequest?.customer).toBe('mock'); + expect(downpaymentRequest?.proration_behavior).toBe('none'); + expect(downpaymentRequest?.['items[0][price_data][unit_amount]']).toBe( + '19900' + ); + expect( + downpaymentRequest?.['items[0][price_data][recurring][interval]'] + ).toBe('month'); + expect( + downpaymentRequest?.['items[0][price_data][recurring][interval_count]'] + ).toBe('3'); + const in2Months = new Date(); + in2Months.setMonth(in2Months.getMonth() + 2); // Atleast 2 months in between (can also be 2 months and 29 days) + // Downpayment should renew after duration (At least 2 months) + expect(parseInt(downpaymentRequest?.trial_end)).toBeGreaterThan( + in2Months.getTime() / 1000 + ); + }); + + it('Logs payments to order history', async () => { + const result = await adminClient.fetch( + 'http://localhost:3050/stripe-subscriptions/webhook', + { + method: 'POST', + body: JSON.stringify({ + type: 'invoice.payment_failed', + data: { + object: { + customer: 'mock', + lines: { + data: [ + { + metadata: { + orderCode: order!.code, + channelToken: 'e2e-default-channel', + }, + }, + ], + }, + }, + }, + } as IncomingStripeWebhook), + } + ); + const ctx = await getDefaultCtx(server); + const history = await server.app + .get(HistoryService) + .getHistoryForOrder(ctx, 1, false); + expect(result.status).toBe(201); + expect( + history.items.find( + (item) => item.data.message === 'Subscription payment failed' + ) + ).toBeDefined(); + }); + + it('Should save payment event', async () => { + const ctx = await getDefaultCtx(server); + const paymentEvents = await server.app + .get(StripeSubscriptionService) + .getPaymentEvents(ctx, {}); + expect(paymentEvents.items?.length).toBeGreaterThan(0); + }); + + it('Should cancel subscription', async () => { + // Mock API + let subscriptionRequests: any[] = []; + nock('https://api.stripe.com') + .post(/subscriptions*/, (body) => { + subscriptionRequests.push(body); + return true; + }) + .reply(200, {}); + await adminClient.query(REMOVE_ORDERLINE, { + input: { + lines: [ + { + orderLineId: 'T_1', + quantity: 1, + }, + ], + orderId: 'T_1', + reason: 'Customer request', + cancelShipping: false, + }, + }); + await new Promise((resolve) => setTimeout(resolve, 1000)); // Await worker processing + expect(subscriptionRequests[0].cancel_at_period_end).toBe('true'); + }); + + it('Should refund subscription', async () => { + // Mock API + let refundRequests: any = []; + nock('https://api.stripe.com') + .post(/refunds*/, (body) => { + refundRequests.push(body); + return true; + }) + .reply(200, {}); + await adminClient.query(REFUND_ORDER, { + input: { + lines: [ + { + orderLineId: 'T_1', + quantity: 1, + }, + ], + reason: 'Customer request', + shipping: 0, + adjustment: 0, + paymentId: 'T_1', + }, + }); + expect(refundRequests[0].amount).toBeDefined(); + }); + + it(`All OrderEvents have ctx.req`, () => { + expect.hasAssertions(); + orderEvents.forEach((event) => { + expect(event.ctx.req).toBeDefined(); + }); + }); + + it('Should fail to create payment intent for ineligible order', async () => { + await shopClient.asUserWithCredentials( + 'trevor_donnelly96@hotmail.com', + 'test' + ); + // Variant 3 is not a subscription product, so the eligiblity checker should not allow intent creation + await shopClient.query(ADD_ITEM_TO_ORDER, { + productVariantId: '3', + quantity: 1, + }); + await setShipping(shopClient); + let error: any; + await shopClient.query(CREATE_PAYMENT_LINK).catch((e) => (error = e)); + expect(error?.message).toBe( + `No eligible payment method found with code 'stripe-subscription'` + ); + }); + }); + + describe('Schedule management', () => { + it('Creates a fixed-date schedule', async () => { + const now = new Date().toISOString(); + const { upsertStripeSubscriptionSchedule: schedule } = + await adminClient.query(UPSERT_SCHEDULES, { + input: { + name: '3 months, billed weekly, fixed date', + downpayment: 0, + durationInterval: SubscriptionInterval.Month, + durationCount: 3, + startMoment: SubscriptionStartMoment.FixedStartdate, + fixedStartDate: now, + billingInterval: SubscriptionInterval.Week, + billingCount: 1, + }, + }); + expect(schedule.startMoment).toBe(SubscriptionStartMoment.FixedStartdate); + expect(schedule.fixedStartDate).toBe(now); + }); + + it('Can retrieve Schedules', async () => { + await adminClient.asSuperAdmin(); + const { stripeSubscriptionSchedules: schedules } = + await adminClient.query(GET_SCHEDULES); + expect(schedules.items[0]).toBeDefined(); + expect(schedules.items[0].id).toBeDefined(); + }); + + it('Can delete Schedules', async () => { + await adminClient.asSuperAdmin(); + const { upsertStripeSubscriptionSchedule: toBeDeleted } = + await adminClient.query(UPSERT_SCHEDULES, { + input: { + name: '6 months, paid in full', + downpayment: 0, + durationInterval: SubscriptionInterval.Month, + durationCount: 6, + startMoment: SubscriptionStartMoment.StartOfBillingInterval, + billingInterval: SubscriptionInterval.Month, + billingCount: 6, + }, + }); + await adminClient.query(DELETE_SCHEDULE, { scheduleId: toBeDeleted.id }); + const { stripeSubscriptionSchedules: schedules } = + await adminClient.query(GET_SCHEDULES); + expect( + schedules.items.find((s: any) => s.id == toBeDeleted.id) + ).toBeUndefined(); + }); + + it('Fails to create fixed-date without start date', async () => { + expect.assertions(1); + const promise = adminClient.query(UPSERT_SCHEDULES, { + input: { + name: '3 months, billed weekly, fixed date', + downpayment: 0, + durationInterval: SubscriptionInterval.Month, + durationCount: 3, + startMoment: SubscriptionStartMoment.FixedStartdate, + billingInterval: SubscriptionInterval.Week, + billingCount: 1, + }, + }); + await expect(promise).rejects.toThrow(); + }); + + it('Fails to create paid-up-front with downpayment', async () => { + expect.assertions(1); + const promise = adminClient.query(UPSERT_SCHEDULES, { + input: { + name: 'Paid up front, $199 downpayment', + downpayment: 19900, + durationInterval: SubscriptionInterval.Month, + durationCount: 6, + startMoment: SubscriptionStartMoment.StartOfBillingInterval, + billingInterval: SubscriptionInterval.Month, + billingCount: 6, + }, + }); + await expect(promise).rejects.toThrow(); + }); + }); + + describe('Publishable key', () => { + it('Should expose publishable key via shop api', async () => { + const { eligiblePaymentMethods } = await shopClient.query( + ELIGIBLE_PAYMENT_METHODS + ); + expect(eligiblePaymentMethods[0].stripeSubscriptionPublishableKey).toBe( + 'test-publishable-key' + ); + }); + }); +}); diff --git a/packages/vendure-plugin-stripe-subscription/test/stripe-test-checkout.plugin.ts b/packages/vendure-plugin-stripe-subscription/test/stripe-test-checkout.plugin.ts index 7294190b..e82ecfdf 100644 --- a/packages/vendure-plugin-stripe-subscription/test/stripe-test-checkout.plugin.ts +++ b/packages/vendure-plugin-stripe-subscription/test/stripe-test-checkout.plugin.ts @@ -1,8 +1,7 @@ import { PluginCommonModule, VendurePlugin } from '@vendure/core'; -import { IncomingStripeWebhook } from '../src'; import { Body, Controller, Get, Headers, Res } from '@nestjs/common'; import { Response } from 'express'; -import { clientSecret } from './dev-server'; +import { intent } from './dev-server'; /** * Return the Stripe intent checkout page @@ -13,8 +12,10 @@ export class CheckoutController { async webhook( @Headers('stripe-signature') signature: string | undefined, @Res() res: Response, - @Body() body: IncomingStripeWebhook + @Body() body: any ): Promise { + const confirmMethod = + intent.intentType === 'SetupIntent' ? 'confirmSetup' : 'confirmPayment'; res.send(` Checkout @@ -37,7 +38,7 @@ export class CheckoutController { // See your keys here: https://dashboard.stripe.com/apikeys const stripe = Stripe('${process.env.STRIPE_PUBLISHABLE_KEY}'); const options = { - clientSecret: '${clientSecret}', + clientSecret: '${intent.clientSecret}', // Fully customizable with appearance API. appearance: {/*...*/}, }; @@ -53,8 +54,8 @@ const form = document.getElementById('payment-form'); form.addEventListener('submit', async (event) => { event.preventDefault(); - // const {error} = await stripe.confirmSetup({ - const {error} = await stripe.confirmPayment({ + // confirmMethod needs to be 'confirmPayment' or 'confirmSetup' + const {error} = await stripe.${confirmMethod}({ //\`Elements\` instance that was used to create the Payment Element elements, confirmParams: { diff --git a/packages/vendure-plugin-stripe-subscription/test/subscriptions.csv b/packages/vendure-plugin-stripe-subscription/test/subscriptions.csv deleted file mode 100644 index d1572aee..00000000 --- a/packages/vendure-plugin-stripe-subscription/test/subscriptions.csv +++ /dev/null @@ -1,4 +0,0 @@ -name ,slug ,description,assets,facets ,optionGroups,optionValues ,sku ,price,taxCategory,stockOnHand,trackInventory,variantAssets,variantFacets -"Adult karate" ,adult-karate ,"" , ,category:karate,"Schedule" ,"Paid in full",adult-full ,540 ,standard ,100 ,false , , - , , , ,category:karate, ,"Recurring" ,adult-recurring,90 ,standard ,100 ,false , , -"A non-sub product","non-subscription", , ,category:nonsub, , ,non-sub-prod ,123 ,standard ,100 ,false , ,