From 241ac6ce8d22bce0b941badd75e9be9a72fa8412 Mon Sep 17 00:00:00 2001 From: Martijn Date: Thu, 21 Sep 2023 15:15:54 +0200 Subject: [PATCH 01/23] feat(stripe-subscription): wip n creating a flexible extensible interfacve --- .../README_backup.md | 371 ++++++++++++++++++ 1 file changed, 371 insertions(+) create mode 100644 packages/vendure-plugin-stripe-subscription/README_backup.md diff --git a/packages/vendure-plugin-stripe-subscription/README_backup.md b/packages/vendure-plugin-stripe-subscription/README_backup.md new file mode 100644 index 00000000..2d12f9e6 --- /dev/null +++ b/packages/vendure-plugin-stripe-subscription/README_backup.md @@ -0,0 +1,371 @@ +# Vendure Stripe Subscription plugin + +![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 +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. + +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. + +Currently, subscriptions auto-renew after their duration: After 12 months, the customer is again billed $300 per 2 +momnths for 12 months. + +## Getting started + +### Setup Stripe webhook + +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` + +## Vendure setup + +3. Add the plugin to your `vendure-config.ts` plugins and admin UI compilation: + +```ts +import { StripeSubscriptionPlugin } from 'vendure-plugin-stripe-subscription'; + +plugins: [ + StripeSubscriptionPlugin, + AdminUiPlugin.init({ + port: 3002, + route: 'admin', + app: compileUiExtensions({ + outputPath: path.join(__dirname, '__admin-ui'), + extensions: [StripeSubscriptionPlugin.ui], + }), + }), +]; +``` + +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. + +## 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. + +It's important to inform your customers what you will be billing them in the +future: https://stripe.com/docs/payments/setup-intents#mandates + +![](docs/subscription-events.png) +_After order settlement you can view the subscription details on the order history_ + +![](docs/sequence.png) +_Subscriptions are created in the background, after a customer has finished the checkout_ + +#### 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: + +```graphql +{ + eligiblePaymentMethods { + id + name + stripeSubscriptionPublishableKey + } +} +``` + +## 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 + +## Paid up front + +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. + +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. + +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. + +## Prorations + +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. + +Proration can be configured on a schedule. With `useProration=false` a customer isn't charged for the remaining days during checkout. + +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. + +## Storefront defined start dates + +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: + +```graphql +mutation { + addItemToOrder( + productVariantId: 1 + quantity: 1 + customFields: { startDate: "2023-01-31T09:18:28.533Z" } + ) { + ... on Order { + id + } + } +} +``` + +## Storefront defined down payments + +A customer can also choose to pay a higher down payment, to lower the recurring costs of a subscription. + +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 + } + } +} +``` + +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 + +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 + } + } +} +``` + +`Downpayment` and `startDate` are optional parameters. Without them, the defaults defined by the schedule will be used. + +### Get subscription pricing details per order line + +You can also get the subscription and Schedule pricing details per order line with the following query: + +```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 + } + } + } +``` + +### Discount subscription payments + +Example of a discount on subscription payments: + +- 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. + +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. + +`StripeSubscriptionPricing.originalrecurringPrice` will have the non-discounted subscription price, while `StripeSubscriptionPricing.recurringPrice` will have the final discounted price. + +### Custom future payments promotions + +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)! + +The `SubscriptionPromotionAction` will discount all subscriptions in an order. + +```ts +// Example fixed discount promotion +import { SubscriptionPromotionAction } from 'vendure-plugin-stripe-subscription'; + +/** + * 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. + +### Contributing and dev server + +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_ +``` + +2. Run `yarn start` +3. Go to `http://localhost:3050/checkout` to view the Stripe checkout From d63b7010326c5ff01e71fedd901cc155910a39dc Mon Sep 17 00:00:00 2001 From: Martijn Date: Thu, 28 Sep 2023 16:00:00 +0200 Subject: [PATCH 02/23] feat(stripe-subscription): new graphql schema --- .../codegen.yml | 5 +- .../api-v2/default-subscription-strategy.ts | 51 ++++ .../src/api-v2/graphql-schema.ts | 71 ++++++ .../src/api-v2/subscription-strategy.ts | 53 ++++ .../src/index.ts | 12 +- .../src/stripe-subscription.plugin.ts | 92 ++----- .../test/dev-server.ts | 214 ++++------------ .../test/dev-server_backup.ts | 237 ++++++++++++++++++ .../test/stripe-test-checkout.plugin.ts | 3 +- 9 files changed, 491 insertions(+), 247 deletions(-) create mode 100644 packages/vendure-plugin-stripe-subscription/src/api-v2/default-subscription-strategy.ts create mode 100644 packages/vendure-plugin-stripe-subscription/src/api-v2/graphql-schema.ts create mode 100644 packages/vendure-plugin-stripe-subscription/src/api-v2/subscription-strategy.ts create mode 100644 packages/vendure-plugin-stripe-subscription/test/dev-server_backup.ts diff --git a/packages/vendure-plugin-stripe-subscription/codegen.yml b/packages/vendure-plugin-stripe-subscription/codegen.yml index 6bdd6d76..7e54214e 100644 --- a/packages/vendure-plugin-stripe-subscription/codegen.yml +++ b/packages/vendure-plugin-stripe-subscription/codegen.yml @@ -1,7 +1,6 @@ -schema: 'src/**/*.ts' -documents: 'src/ui/queries.ts' +schema: 'src/api-v2/graphql-schema.ts' generates: - ./src/ui/generated/graphql.ts: + ./src/api-v2/generated/graphql.ts: plugins: - typescript - typescript-operations diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/default-subscription-strategy.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/default-subscription-strategy.ts new file mode 100644 index 00000000..5d79ad5f --- /dev/null +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/default-subscription-strategy.ts @@ -0,0 +1,51 @@ +import { + RequestContext, + OrderLine, + Injector, + ProductVariant, +} 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, + orderLine: OrderLine + ): Subscription { + return this.getSubscriptionForVariant(orderLine.productVariant); + } + + previewSubscription( + ctx: RequestContext, + injector: Injector, + productVariant: ProductVariant + ): Subscription { + return this.getSubscriptionForVariant(productVariant); + } + + private getSubscriptionForVariant( + productVariant: ProductVariant + ): Subscription { + const price = productVariant.listPrice; + return { + priceIncludesTax: productVariant.listPriceIncludesTax, + amountDueNow: price, + recurring: { + amount: price, + interval: 'month', + intervalCount: 1, + startDate: this.getOneMonthFromNow(), + }, + }; + } + + private getOneMonthFromNow(): Date { + var now = new Date(); + return new Date(now.getFullYear(), now.getMonth() + 1, 1); + } +} diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/graphql-schema.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/graphql-schema.ts new file mode 100644 index 00000000..9bd50256 --- /dev/null +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/graphql-schema.ts @@ -0,0 +1,71 @@ +import { gql } from 'graphql-tag'; + +/** + * Needed for gql codegen + */ +const _codegenAdditions = gql` + scalar DateTime + scalar JSON +`; + +export const shopSchemaExtensions = gql` + union StripeSubscription = + StripeSubscriptionOneTimePayment + | StripeSubscriptionRecurringPayment + | StripeSubscriptionBothPaymentTypes + + enum StripeSubscriptionInterval { + week + month + year + } + + type StripeSubscriptionBothPaymentTypes { + priceIncludesTax: Boolean! + amountDueNow: Int! + recurring: StripeSubscriptionRecurringPaymentDefinition! + } + + type StripeSubscriptionOneTimePayment { + priceIncludesTax: Boolean! + amountDueNow: Int! + } + + type StripeSubscriptionRecurringPayment { + priceIncludesTax: Boolean! + recurring: StripeSubscriptionRecurringPaymentDefinition! + } + + type StripeSubscriptionRecurringPaymentDefinition { + amount: Int! + interval: StripeSubscriptionInterval + intervalCount: Int! + startDate: DateTime + } + + enum StripeSubscriptionIntentType { + PaymentIntent + SetupIntent + } + + type StripeSubscriptionIntent { + clientSecret: String! + intentType: StripeSubscriptionIntentType! + } + + extend type PaymentMethodQuote { + stripeSubscriptionPublishableKey: String + } + + extend type Query { + previewStripeSubscription( + productVariantId: ID! + customInputs: JSON + ): StripeSubscription! + previewStripeSubscriptionForProduct(productId: ID!): [StripeSubscription!]! + } + + extend type Mutation { + createStripeSubscriptionIntent: String! + } +`; diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/subscription-strategy.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/subscription-strategy.ts new file mode 100644 index 00000000..0189a7ca --- /dev/null +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/subscription-strategy.ts @@ -0,0 +1,53 @@ +import { + OrderLine, + RequestContext, + Injector, + ProductVariant, +} from '@vendure/core'; + +/** + * Subscriptions can be created for One time payments, Recurring payments or a combination of the two + */ +export type Subscription = + | OneTimePayment + | RecurringPayment + | (OneTimePayment & RecurringPayment); + +export interface OneTimePayment { + priceIncludesTax: boolean; + amountDueNow: number; +} + +export interface RecurringPayment { + priceIncludesTax: boolean; + recurring: { + amount: number; + interval: 'week' | 'month' | 'year'; + intervalCount: number; + startDate: Date; + }; +} + +export interface SubscriptionStrategy { + /** + * Define a subscription based on the given order line. + * This is executed when an item is being added to cart + */ + defineSubscription( + ctx: RequestContext, + injector: Injector, + orderLine: OrderLine + ): 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, to, for example, 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; +} diff --git a/packages/vendure-plugin-stripe-subscription/src/index.ts b/packages/vendure-plugin-stripe-subscription/src/index.ts index d575e490..62572608 100644 --- a/packages/vendure-plugin-stripe-subscription/src/index.ts +++ b/packages/vendure-plugin-stripe-subscription/src/index.ts @@ -1,11 +1,3 @@ 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/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/default-subscription-strategy'; +export * from './api/subscription-strategy'; 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..8c74f07b 100644 --- a/packages/vendure-plugin-stripe-subscription/src/stripe-subscription.plugin.ts +++ b/packages/vendure-plugin-stripe-subscription/src/stripe-subscription.plugin.ts @@ -1,105 +1,57 @@ 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-v2/subscription-strategy'; +import { shopSchemaExtensions } from './api-v2/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 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 { 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 { DefaultSubscriptionStrategy } from './api-v2/default-subscription-strategy'; export interface StripeSubscriptionPluginOptions { /** * Only use this for testing purposes, NEVER in production */ - disableWebhookSignatureChecking: boolean; + disableWebhookSignatureChecking?: boolean; + subscriptionStrategy?: SubscriptionStrategy; } @VendurePlugin({ imports: [PluginCommonModule], - entities: [Schedule, StripeSubscriptionPayment], shopApiExtensions: { schema: shopSchemaExtensions, - resolvers: [ShopResolver, OrderLinePricingResolver], + resolvers: [], }, - adminApiExtensions: { - schema: adminSchemaExtensions, - resolvers: [ - AdminResolver, - AdminPriceIncludesTaxResolver, - OrderLinePricingResolver, - ], - }, - controllers: [StripeSubscriptionController], + controllers: [], providers: [ - StripeSubscriptionService, - ScheduleService, { provide: PLUGIN_INIT_OPTIONS, useFactory: () => StripeSubscriptionPlugin.options, }, ], configuration: (config) => { - config.paymentOptions.paymentMethodHandlers.push(stripeSubscriptionHandler); - config.paymentOptions.paymentMethodEligibilityCheckers = [ - ...(config.paymentOptions.paymentMethodEligibilityCheckers ?? []), - hasStripeSubscriptionProductsPaymentChecker, - ]; + // FIXME config.paymentOptions.paymentMethodHandlers.push(stripeSubscriptionHandler); + // FIXME config.paymentOptions.paymentMethodEligibilityCheckers = [ + // ...(config.paymentOptions.paymentMethodEligibilityCheckers ?? []), + // hasStripeSubscriptionProductsPaymentChecker, + // ]; 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); + // FIXME config.orderOptions.orderItemPriceCalculationStrategy = + // new SubscriptionOrderItemCalculation(); return config; }, compatibility: '^2.0.0', }) export class StripeSubscriptionPlugin { - static options: StripeSubscriptionPluginOptions; + static options: StripeSubscriptionPluginOptions = { + disableWebhookSignatureChecking: false, + subscriptionStrategy: new DefaultSubscriptionStrategy(), + }; static init(options: StripeSubscriptionPluginOptions) { - this.options = options; + this.options = { + ...this.options, + ...options, + }; return StripeSubscriptionPlugin; } - - static ui: AdminUiExtension = { - 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', - ngModuleName: 'StripeSubscriptionSharedModule', - }, - ], - }; } diff --git a/packages/vendure-plugin-stripe-subscription/test/dev-server.ts b/packages/vendure-plugin-stripe-subscription/test/dev-server.ts index f1818456..74208ce9 100644 --- a/packages/vendure-plugin-stripe-subscription/test/dev-server.ts +++ b/packages/vendure-plugin-stripe-subscription/test/dev-server.ts @@ -1,39 +1,24 @@ -import { - createTestEnvironment, - registerInitializer, - SqljsInitializer, -} from '@vendure/testing'; +import { AdminUiPlugin } from '@vendure/admin-ui-plugin'; import { DefaultLogger, DefaultSearchPlugin, - LanguageCode, LogLevel, 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 { StripeSubscriptionPlugin } from '../src/stripe-subscription.plugin'; import { ADD_ITEM_TO_ORDER, CREATE_PAYMENT_LINK, - CREATE_PAYMENT_METHOD, setShipping, UPDATE_CHANNEL, UPDATE_VARIANT, } from './helpers'; - -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'; +import { StripeTestCheckoutPlugin } from './stripe-test-checkout.plugin'; export let clientSecret = 'test'; @@ -61,16 +46,16 @@ export let clientSecret = 'test'; AdminUiPlugin.init({ port: 3002, route: 'admin', - app: process.env.COMPILE_ADMIN - ? compileUiExtensions({ - outputPath: path.join(__dirname, '__admin-ui'), - extensions: [StripeSubscriptionPlugin.ui], - devMode: true, - }) - : // otherwise used precompiled files. Might need to run once using devMode: false - { - path: path.join(__dirname, '__admin-ui/dist'), - }, + // app: process.env.COMPILE_ADMIN + // ? compileUiExtensions({ + // outputPath: path.join(__dirname, '__admin-ui'), + // extensions: [StripeSubscriptionPlugin.ui], + // devMode: true, + // }) + // : // otherwise used precompiled files. Might need to run once using devMode: false + // { + // path: path.join(__dirname, '__admin-ui/dist'), + // }, }), ], }); @@ -95,140 +80,45 @@ export let clientSecret = 'test'; 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.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(`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, - }, - }); */ - 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', + // await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test'); + // let { addItemToOrder: order } = await shopClient.query(ADD_ITEM_TO_ORDER, { + // productVariantId: '1', // 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: secret } = await shopClient.query( + // CREATE_PAYMENT_LINK + // ); + // clientSecret = secret; + // console.log(`Go to http://localhost:3050/checkout/ to test your intent`); // Uncomment these lines to list all subscriptions created in Stripe // const ctx = await server.app.get(RequestContextService).create({apiType: 'admin'}); diff --git a/packages/vendure-plugin-stripe-subscription/test/dev-server_backup.ts b/packages/vendure-plugin-stripe-subscription/test/dev-server_backup.ts new file mode 100644 index 00000000..f1818456 --- /dev/null +++ b/packages/vendure-plugin-stripe-subscription/test/dev-server_backup.ts @@ -0,0 +1,237 @@ +import { + createTestEnvironment, + registerInitializer, + SqljsInitializer, +} from '@vendure/testing'; +import { + DefaultLogger, + DefaultSearchPlugin, + LanguageCode, + LogLevel, + mergeConfig, + RequestContextService, +} from '@vendure/core'; +import { AdminUiPlugin } from '@vendure/admin-ui-plugin'; +import { StripeTestCheckoutPlugin } from './stripe-test-checkout.plugin'; +import { + ADD_ITEM_TO_ORDER, + CREATE_PAYMENT_LINK, + CREATE_PAYMENT_METHOD, + setShipping, + UPDATE_CHANNEL, + UPDATE_VARIANT, +} from './helpers'; + +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'; + +/** + * Use something like NGROK to start a reverse tunnel to receive webhooks: ngrok http 3050 + * Set the generated url as webhook endpoint in your Stripe account: https://8837-85-145-210-58.eu.ngrok.io/stripe-subscriptions/webhook + * Make sure it listens for all checkout events. This can be configured in Stripe when setting the webhook + * Now, you are ready to `yarn start` + * The logs will display a link that can be used to subscribe via Stripe + */ +(async () => { + require('dotenv').config(); + const { testConfig } = require('@vendure/testing'); + registerInitializer('sqljs', new SqljsInitializer('__data__')); + const config = mergeConfig(testConfig, { + logger: new DefaultLogger({ level: LogLevel.Debug }), + apiOptions: { + adminApiPlayground: {}, + shopApiPlayground: {}, + }, + plugins: [ + StripeTestCheckoutPlugin, + StripeSubscriptionPlugin, + DefaultSearchPlugin, + AdminUiPlugin.init({ + port: 3002, + route: 'admin', + app: process.env.COMPILE_ADMIN + ? compileUiExtensions({ + outputPath: path.join(__dirname, '__admin-ui'), + extensions: [StripeSubscriptionPlugin.ui], + devMode: true, + }) + : // otherwise used precompiled files. Might need to run once using devMode: false + { + path: path.join(__dirname, '__admin-ui/dist'), + }, + }), + ], + }); + const { server, shopClient, adminClient } = createTestEnvironment(config); + await server.init({ + initialData: { + ...require('../../test/src/initial-data').initialData, + shippingMethods: [{ name: 'Standard Shipping', price: 0 }], + }, + productsCsvPath: `${__dirname}/subscriptions.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, + // }, + // }, + // ], + // }); + console.log(`Added schedule to variants`); + // Prepare order + await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test'); + + // This is the variant for checkout + /* await shopClient.query(ADD_ITEM_TO_ORDER, { + productVariantId: '2', + quantity: 1, + customFields: { + // downpayment: 40000, + // startDate: in3Days, + }, + }); */ + 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`); + + // 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)); +})(); 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..fd1befce 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,5 +1,4 @@ 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'; @@ -13,7 +12,7 @@ export class CheckoutController { async webhook( @Headers('stripe-signature') signature: string | undefined, @Res() res: Response, - @Body() body: IncomingStripeWebhook + @Body() body: any ): Promise { res.send(` From fdbd35f9b2b14fedf737dc3912b5d803d6aaba9f Mon Sep 17 00:00:00 2001 From: Martijn Date: Thu, 28 Sep 2023 16:13:53 +0200 Subject: [PATCH 03/23] feat(stripe-subscription): minor improv --- .../src/api-v2/graphql-schema.ts | 5 +++-- .../src/api-v2/subscription-strategy.ts | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/graphql-schema.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/graphql-schema.ts index 9bd50256..fc1adcdf 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/graphql-schema.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/graphql-schema.ts @@ -38,9 +38,10 @@ export const shopSchemaExtensions = gql` type StripeSubscriptionRecurringPaymentDefinition { amount: Int! - interval: StripeSubscriptionInterval + interval: StripeSubscriptionInterval! intervalCount: Int! - startDate: DateTime + startDate: DateTime! + endDate: DateTime } enum StripeSubscriptionIntentType { diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/subscription-strategy.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/subscription-strategy.ts index 0189a7ca..a4632691 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/subscription-strategy.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/subscription-strategy.ts @@ -25,6 +25,7 @@ export interface RecurringPayment { interval: 'week' | 'month' | 'year'; intervalCount: number; startDate: Date; + endDate?: Date; }; } From 2cc81d70d57d5487eaa61661a1f6d93a21a342ad Mon Sep 17 00:00:00 2001 From: Martijn Date: Sat, 30 Sep 2023 10:42:52 +0200 Subject: [PATCH 04/23] feat(stripe-subscription): wip --- .../default-subscription-strategy.ts | 5 + .../{ => strategy}/subscription-strategy.ts | 6 + .../src/api-v2/stripe-subscription.service.ts | 583 ++++++++++++++++++ .../src/api-v2/stripe.client.ts | 116 ++++ .../src/api-v2/types/stripe-invoice.ts | 200 ++++++ .../src/api-v2/types/stripe-payment-intent.ts | 186 ++++++ .../src/api-v2/types/stripe.common.ts | 30 + .../src/api-v2/util.ts | 6 + .../api-v2/vendure-config/custom-fields.d.ts | 19 + .../api-v2/vendure-config/custom-fields.ts | 59 ++ ...e-subscription-products-payment-checker.ts | 30 + .../stripe-subscription.handler.ts | 123 ++++ .../src/stripe-subscription.plugin.ts | 32 +- .../payments-component.html | 101 --- .../payments-component/payments.component.ts | 81 --- .../src/ui/queries.ts | 76 --- .../schedule-relation-selector.component.ts | 43 -- .../schedules.component.html | 347 ----------- .../schedules.component.scss | 61 -- .../schedules.component.ts | 324 ---------- .../ui/stripe-subscription-shared.module.ts | 26 +- .../src/ui/stripe-subscription.module.ts | 26 - 22 files changed, 1389 insertions(+), 1091 deletions(-) rename packages/vendure-plugin-stripe-subscription/src/api-v2/{ => strategy}/default-subscription-strategy.ts (89%) rename packages/vendure-plugin-stripe-subscription/src/api-v2/{ => strategy}/subscription-strategy.ts (88%) create mode 100644 packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts create mode 100644 packages/vendure-plugin-stripe-subscription/src/api-v2/stripe.client.ts create mode 100644 packages/vendure-plugin-stripe-subscription/src/api-v2/types/stripe-invoice.ts create mode 100644 packages/vendure-plugin-stripe-subscription/src/api-v2/types/stripe-payment-intent.ts create mode 100644 packages/vendure-plugin-stripe-subscription/src/api-v2/types/stripe.common.ts create mode 100644 packages/vendure-plugin-stripe-subscription/src/api-v2/util.ts create mode 100644 packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/custom-fields.d.ts create mode 100644 packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/custom-fields.ts create mode 100644 packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/has-stripe-subscription-products-payment-checker.ts create mode 100644 packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/stripe-subscription.handler.ts delete mode 100644 packages/vendure-plugin-stripe-subscription/src/ui/payments-component/payments-component.html delete mode 100644 packages/vendure-plugin-stripe-subscription/src/ui/payments-component/payments.component.ts delete mode 100644 packages/vendure-plugin-stripe-subscription/src/ui/queries.ts delete mode 100644 packages/vendure-plugin-stripe-subscription/src/ui/schedule-relation-selector.component.ts delete mode 100644 packages/vendure-plugin-stripe-subscription/src/ui/schedules-component/schedules.component.html delete mode 100644 packages/vendure-plugin-stripe-subscription/src/ui/schedules-component/schedules.component.scss delete mode 100644 packages/vendure-plugin-stripe-subscription/src/ui/schedules-component/schedules.component.ts delete mode 100644 packages/vendure-plugin-stripe-subscription/src/ui/stripe-subscription.module.ts diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/default-subscription-strategy.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/default-subscription-strategy.ts similarity index 89% rename from packages/vendure-plugin-stripe-subscription/src/api-v2/default-subscription-strategy.ts rename to packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/default-subscription-strategy.ts index 5d79ad5f..1de055c5 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/default-subscription-strategy.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/default-subscription-strategy.ts @@ -20,6 +20,11 @@ export class DefaultSubscriptionStrategy implements SubscriptionStrategy { return this.getSubscriptionForVariant(orderLine.productVariant); } + isSubscription(ctx: RequestContext, orderLineWithVariant: OrderLine): boolean { + // This example treats all products as subscriptions + return true; + } + previewSubscription( ctx: RequestContext, injector: Injector, diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/subscription-strategy.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/subscription-strategy.ts similarity index 88% rename from packages/vendure-plugin-stripe-subscription/src/api-v2/subscription-strategy.ts rename to packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/subscription-strategy.ts index a4632691..f525ad64 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/subscription-strategy.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/subscription-strategy.ts @@ -30,6 +30,12 @@ export interface RecurringPayment { } export interface SubscriptionStrategy { + + /** + * Determines if the given orderline should be treated as a subscription, or as a regular product + */ + isSubscription(ctx: RequestContext, orderLineWithVariant: OrderLine): boolean; + /** * Define a subscription based on the given order line. * This is executed when an item is being added to cart diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts new file mode 100644 index 00000000..1619a86c --- /dev/null +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts @@ -0,0 +1,583 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { StockMovementType } from '@vendure/common/lib/generated-types'; +import { + ActiveOrderService, ChannelService, + CustomerService, + EntityHydrator, + ErrorResult, + EventBus, + HistoryService, + ID, JobQueue, + JobQueueService, + LanguageCode, Logger, + Order, + OrderLine, + OrderLineEvent, + OrderService, + OrderStateTransitionError, PaymentMethod, + PaymentMethodService, RequestContext, + SerializedRequestContext, + StockMovementEvent, + TransactionalConnection, + UserInputError +} from '@vendure/core'; +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 { 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 { + StripeSubscriptionPricing +} from '../ui/generated/graphql'; +import { StripeSubscriptionIntent, StripeSubscriptionIntentType } from './generated/graphql'; +import { SubscriptionStrategy } from './strategy/subscription-strategy'; +import { stripeSubscriptionHandler } from './payment-method/stripe-subscription.handler'; +import { StripeClient } from './stripe.client'; +import './vendure-config/custom-fields.d.ts' +import { StripeInvoice } from './types/stripe-invoice'; +import { printMoney } from './util'; + +export interface StripeContext { + paymentMethod: PaymentMethod; + stripeClient: StripeClient; +} + +interface CreateSubscriptionsJob { + action: 'createSubscriptionsForOrder'; + ctx: SerializedRequestContext; + orderCode: string; + stripeCustomerId: string; + stripePaymentMethodId: string; +} + +interface CancelSubscriptionsJob { + action: 'cancelSubscriptionsForOrderline'; + ctx: SerializedRequestContext; + orderLineId: ID; +} + +export type JobData = CreateSubscriptionsJob | CancelSubscriptionsJob; + +@Injectable() +export class StripeSubscriptionService { + constructor( + private paymentMethodService: PaymentMethodService, + private activeOrderService: ActiveOrderService, + private entityHydrator: EntityHydrator, + private channelService: ChannelService, + private orderService: OrderService, + private historyService: HistoryService, + private eventBus: EventBus, + private jobQueueService: JobQueueService, + private customerService: CustomerService, + private connection: TransactionalConnection, + @Inject(PLUGIN_INIT_OPTIONS) private options: StripeSubscriptionPluginOptions + ) { + this.strategy = this.options.subscriptionStrategy! + } + + private jobQueue!: JobQueue; + readonly strategy: SubscriptionStrategy; + + async onModuleInit() { + // Create jobQueue with handlers + this.jobQueue = await this.jobQueueService.createQueue({ + name: 'stripe-subscription', + process: async ({ data, id }) => { + const ctx = RequestContext.deserialize(data.ctx); + if (data.action === 'cancelSubscriptionsForOrderline') { + this.cancelSubscriptionForOrderLine(ctx, data.orderLineId); + } else if (data.action === 'createSubscriptionsForOrder') { + const order = await this.orderService.findOneByCode( + ctx, + data.orderCode, + [] + ); + try { + await this.createSubscriptions( + ctx, + data.orderCode, + data.stripeCustomerId, + data.stripePaymentMethodId + ); + } catch (error) { + Logger.warn( + `Failed to process job ${data.action} (${id}) for channel ${data.ctx._channel.token}: ${error}`, + loggerCtx + ); + if (order) { + await this.logHistoryEntry( + ctx, + order.id, + 'Failed to create subscription', + error + ); + } + throw error; + } + } + }, + }); + // Add unique hash for subscriptions, so Vendure creates a new order line + this.eventBus.ofType(OrderLineEvent).subscribe(async (event) => { + if ( + event.type === 'created' && + this.strategy.isSubscription(event.ctx, event.orderLine) + ) { + await this.connection + .getRepository(event.ctx, OrderLine) + .update( + { id: event.orderLine.id }, + { customFields: { subscriptionHash: randomUUID() } } + ); + } + }); + // Listen for stock cancellation or release events, to cancel an order lines subscription + this.eventBus + .ofType(StockMovementEvent) + .pipe( + filter( + (event) => + event.type === StockMovementType.RELEASE || + event.type === StockMovementType.CANCELLATION + ) + ) + .subscribe(async (event) => { + const cancelOrReleaseEvents = event.stockMovements as (Cancellation | Release)[]; + const orderLinesWithSubscriptions = cancelOrReleaseEvents + // Filter out non-sub orderlines + .filter((event) => (event.orderLine.customFields).subscriptionIds); + await Promise.all( + // Push jobs + orderLinesWithSubscriptions.map((line) => + this.jobQueue.add({ + ctx: event.ctx.serialize(), + action: 'cancelSubscriptionsForOrderline', + orderLineId: line.id, + }) + ) + ); + }); + } + + async cancelSubscriptionForOrderLine( + ctx: RequestContext, + orderLineId: ID + ): Promise { + 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); + if (!line?.customFields.subscriptionIds?.length) { + return Logger.info( + `OrderLine ${orderLineId} of ${orderLineId} has no subscriptionIds. Not cancelling anything... `, + loggerCtx + ); + } + await this.entityHydrator.hydrate(ctx, line, { relations: ['order'] }); + const { stripeClient } = await this.getStripeContext(ctx); + for (const subscriptionId of line.customFields.subscriptionIds) { + try { + await stripeClient.subscriptions.update(subscriptionId, { + cancel_at_period_end: true, + }); + Logger.info(`Cancelled subscription ${subscriptionId}`); + await this.logHistoryEntry( + ctx, + order!.id, + `Cancelled subscription ${subscriptionId}`, + undefined, + undefined, + subscriptionId + ); + } catch (e: unknown) { + Logger.error( + `Failed to cancel subscription ${subscriptionId}`, + loggerCtx + ); + await this.logHistoryEntry( + ctx, + order.id, + `Failed to cancel ${subscriptionId}`, + e, + undefined, + subscriptionId + ); + } + } + } + + /** + * Proxy to Stripe to retrieve subscriptions created for the current channel. + * Proxies to the Stripe api, so you can use the same filtering, parameters and options as defined here + * https://stripe.com/docs/api/subscriptions/list + */ + async getAllSubscriptions( + ctx: RequestContext, + params?: Stripe.SubscriptionListParams, + options?: Stripe.RequestOptions + ): Promise> { + const { stripeClient } = await this.getStripeContext(ctx); + return stripeClient.subscriptions.list(params, options); + } + + /** + * Get a subscription directly from Stripe + */ + async getSubscription( + ctx: RequestContext, + subscriptionId: string + ): Promise> { + const { stripeClient } = await this.getStripeContext(ctx); + return stripeClient.subscriptions.retrieve(subscriptionId); + } + + async createIntent(ctx: RequestContext): Promise { + let order = (await this.activeOrderService.getActiveOrder( + ctx, + undefined + )) as OrderWithSubscriptionFields; + 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'], + }); + if (!order.lines?.length) { + throw new UserInputError('Cannot create payment intent for empty order'); + } + if (!order.customer) { + throw new UserInputError( + 'Cannot create payment intent for order without customer' + ); + } + if (!order.shippingLines?.length) { + throw new UserInputError( + 'Cannot create payment intent for order without shippingMethod' + ); + } + // Check if Stripe Subscription paymentMethod is eligible for this order + const eligibleStripeMethodCodes = ( + await this.orderService.getEligiblePaymentMethods(ctx, order.id) + ) + .filter((m) => m.isEligible) + .map((m) => m.code); + const { stripeClient, paymentMethod } = await this.getStripeContext(ctx); + if (!eligibleStripeMethodCodes.includes(paymentMethod.code)) { + throw new UserInputError( + `No eligible payment method found with code \'stripe-subscription\'` + ); + } + 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 + ) + ); + // FIXME + const hasSubscriptionProducts = order.lines.some( + (l) => l.productVariant.customFields.subscriptionSchedule + ); + // FIXME create Setup or PaymentIntent + 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, + amount: order.totalWithTax, + }, + }); + + // FIXME + const intentType = StripeSubscriptionIntentType.SetupIntent // FIXME + Logger.info( + `Created ${intentType} '${intent.id}' for order ${order.code}`, + loggerCtx + ); + if (!intent.client_secret) { + throw Error(`No client_secret found in ${intentType} response, something went wrong!`); + } + return { + clientSecret: intent.client_secret, + intentType + } + } + + hasSubscriptionProducts(ctx: RequestContext, order: Order): boolean { + return order.lines.some( + (l) => this.strategy.isSubscription(ctx, l) + ); + } + + /** + * Handle failed subscription payments that come in after the initial payment intent + */ + async handleInvoicePaymentFailed( + ctx: RequestContext, + object: StripeInvoice, + order: Order + ): Promise { + const amount = object.lines?.data[0]?.plan?.amount; + const message = amount + ? `Subscription payment of ${printMoney(amount)} failed` + : 'Subscription payment failed'; + await this.logHistoryEntry( + ctx, + order.id, + message, + `${message} - ${object.id}`, + undefined, + object.subscription + ); + } + + /** + * Handle the initial payment Intent succeeded. + * Creates subscriptions in Stripe for customer attached to this order + */ + async handlePaymentIntentSucceeded( + ctx: RequestContext, + object: StripePaymentIntent, + order: Order + ): Promise { + const { + paymentMethod: { code: paymentMethodCode }, + } = await this.getStripeContext(ctx); + if (!object.customer) { + await this.logHistoryEntry( + ctx, + order.id, + '', + `No customer ID found in incoming webhook. Can not create subscriptions for this order.` + ); + throw Error(`No customer found in webhook data for order ${order.code}`); + } + // Create subscriptions for customer + this.jobQueue + .add( + { + action: 'createSubscriptionsForOrder', + ctx: ctx.serialize(), + orderCode: order.code, + stripePaymentMethodId: object.payment_method, + stripeCustomerId: object.customer, + }, + { retries: 0 } // Only 1 try, because subscription creation isn't transaction-proof + ) + .catch((e) => + Logger.error( + `Failed to add subscription-creation job to queue`, + loggerCtx + ) + ); + // Status is complete, we can settle payment + if (order.state !== 'ArrangingPayment') { + const transitionToStateResult = await this.orderService.transitionToState( + ctx, + order.id, + 'ArrangingPayment' + ); + if (transitionToStateResult instanceof OrderStateTransitionError) { + throw Error( + `Error transitioning order ${order.code} from ${transitionToStateResult.fromState} to ${transitionToStateResult.toState}: ${transitionToStateResult.message}` + ); + } + } + const addPaymentToOrderResult = await this.orderService.addPaymentToOrder( + ctx, + order.id, + { + method: paymentMethodCode, + metadata: { + setupIntentId: object.id, + amount: object.metadata.amount, + }, + } + ); + if ((addPaymentToOrderResult as ErrorResult).errorCode) { + throw Error( + `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}`, + loggerCtx + ); + } + + /** + * Create subscriptions for customer based on order + */ + private async createSubscriptions( + ctx: RequestContext, + orderCode: string, + stripeCustomerId: string, + stripePaymentMethodId: string + ): Promise { + const order = (await this.orderService.findOneByCode(ctx, orderCode, [ + 'customer', + 'lines', + 'lines.productVariant', + ])) as OrderWithSubscriptionFields; + if (!order) { + throw Error(`Cannot find order with code ${orderCode}`); + } + try { + // FIXME do stuff here + + + + + + // await this.saveSubscriptionIds(ctx, orderLine.id, createdSubscriptions); + } 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 } }); + } + + async createContext( + channelToken: string, + req: Request + ): Promise { + const channel = await this.channelService.getChannelFromToken(channelToken); + return new RequestContext({ + apiType: 'admin', + isAuthorized: true, + authorizedAsOwnerOnly: false, + channel, + languageCode: LanguageCode.en, + req, + }); + } + + /** + * Get the Stripe context for the current channel. + * The Stripe context consists of the Stripe client and the Vendure payment method connected to the Stripe account + */ + async getStripeContext(ctx: RequestContext): Promise { + const paymentMethods = await this.paymentMethodService.findAll(ctx, { + filter: { enabled: { eq: true } }, + }); + const stripePaymentMethods = paymentMethods.items.filter( + (pm) => pm.handler.code === stripeSubscriptionHandler.code + ); + if (stripePaymentMethods.length > 1) { + throw new UserInputError( + `Multiple payment methods found with handler 'stripe-subscription', there should only be 1 per channel!` + ); + } + const paymentMethod = stripePaymentMethods[0]; + if (!paymentMethod) { + throw new UserInputError( + `No enabled payment method found with handler 'stripe-subscription'` + ); + } + const apiKey = paymentMethod.handler.args.find( + (arg) => arg.name === 'apiKey' + )?.value; + let webhookSecret = paymentMethod.handler.args.find( + (arg) => arg.name === 'webhookSecret' + )?.value; + if (!apiKey || !webhookSecret) { + Logger.warn( + `No api key or webhook secret is configured for ${paymentMethod.code}`, + loggerCtx + ); + throw Error( + `Payment method ${paymentMethod.code} has no api key or webhook secret configured` + ); + } + return { + paymentMethod: paymentMethod, + stripeClient: new StripeClient(webhookSecret, apiKey, { + apiVersion: null as any, // Null uses accounts default version + }), + }; + } + + async logHistoryEntry( + ctx: RequestContext, + orderId: ID, + message: string, + error?: unknown, + pricing?: StripeSubscriptionPricing, + 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, + orderId, + type: 'STRIPE_SUBSCRIPTION_NOTIFICATION' as any, + data: { + message, + valid: !error, + error: prettifiedError, + subscriptionId, + pricing: prettifierPricing, + }, + }, + false + ); + } +} diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe.client.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe.client.ts new file mode 100644 index 00000000..bac0786b --- /dev/null +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe.client.ts @@ -0,0 +1,116 @@ +import Stripe from 'stripe'; +import { CustomerWithSubscriptionFields } from './vendure-config/custom-fields'; + +interface SubscriptionInput { + customerId: string; + productId: string; + currencyCode: string; + amount: number; + interval: Stripe.SubscriptionCreateParams.Item.PriceData.Recurring.Interval; + intervalCount: number; + paymentMethodId: string; + startDate: Date; + orderCode: string; + channelToken: string; + endDate?: Date; + description?: string; +} + +/** + * Wrapper around the Stripe client with specifics for this subscription plugin + */ +export class StripeClient extends Stripe { + constructor( + public webhookSecret: string, + apiKey: string, + config: Stripe.StripeConfig + ) { + 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; + } + } + const stripeCustomers = await this.customers.list({ + email: customer.emailAddress, + }); + if (stripeCustomers.data.length > 0) { + return stripeCustomers.data[0]; + } + return await this.customers.create({ + email: customer.emailAddress, + name: `${customer.firstName} ${customer.lastName}`, + }); + } + + /** + * Throws an error if incoming webhook signature is invalid + */ + validateWebhookSignature( + payload: Buffer, + signature: string | undefined + ): void { + if (!signature) { + throw Error(`Can not validate webhook signature without a signature!`); + } + this.webhooks.constructEvent(payload, signature, this.webhookSecret); + } + + async createOffSessionSubscription({ + customerId, + productId, + currencyCode, + amount, + interval, + intervalCount, + paymentMethodId, + startDate, + endDate, + description, + orderCode, + channelToken, + }: SubscriptionInput): Promise { + return this.subscriptions.create({ + 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, + items: [ + { + price_data: { + product: productId, + currency: currencyCode, + unit_amount: amount, + recurring: { + interval: interval, + interval_count: intervalCount, + }, + }, + }, + ], + off_session: true, + default_payment_method: paymentMethodId, + payment_behavior: 'allow_incomplete', + metadata: { + orderCode, + channelToken, + }, + }); + } + + toStripeTimeStamp(date: Date): number { + return Math.round(date.getTime() / 1000); + } +} diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/types/stripe-invoice.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/types/stripe-invoice.ts new file mode 100644 index 00000000..db4526c9 --- /dev/null +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/types/stripe-invoice.ts @@ -0,0 +1,200 @@ +import { Metadata } from './stripe.common'; + +export interface StripeInvoice { + id: string; + object: string; + account_country: string; + account_name: string; + account_tax_ids: any; + amount_due: number; + amount_paid: number; + amount_remaining: number; + amount_shipping: number; + application: any; + application_fee_amount: any; + attempt_count: number; + attempted: boolean; + auto_advance: boolean; + automatic_tax: AutomaticTax; + billing_reason: string; + charge: any; + collection_method: string; + created: number; + currency: string; + custom_fields: any; + customer: string; + customer_address: any; + customer_email: string; + customer_name: string; + customer_phone: any; + customer_shipping: any; + customer_tax_exempt: string; + customer_tax_ids: any[]; + default_payment_method: any; + default_source: any; + default_tax_rates: any[]; + description: string; + discount: any; + discounts: any[]; + due_date: any; + effective_at: number; + ending_balance: number; + footer: any; + from_invoice: any; + hosted_invoice_url: string; + invoice_pdf: string; + last_finalization_error: any; + latest_revision: any; + lines: Lines; + livemode: boolean; + metadata: Metadata; + next_payment_attempt: any; + number: string; + on_behalf_of: any; + paid: boolean; + paid_out_of_band: boolean; + payment_intent: any; + payment_settings: PaymentSettings; + period_end: number; + period_start: number; + post_payment_credit_notes_amount: number; + pre_payment_credit_notes_amount: number; + quote: any; + receipt_number: any; + rendering_options: any; + shipping_cost: any; + shipping_details: any; + starting_balance: number; + statement_descriptor: any; + status: string; + status_transitions: StatusTransitions; + subscription: string; + subscription_details: SubscriptionDetails; + subtotal: number; + subtotal_excluding_tax: number; + tax: any; + test_clock: any; + total: number; + total_discount_amounts: any[]; + total_excluding_tax: number; + total_tax_amounts: any[]; + transfer_data: any; + webhooks_delivered_at: number; +} + +export interface AutomaticTax { + enabled: boolean; + status: any; +} + +export interface Lines { + object: string; + data: Daum[]; + has_more: boolean; + total_count: number; + url: string; +} + +export interface Daum { + id: string; + object: string; + amount: number; + amount_excluding_tax: number; + currency: string; + description: string; + discount_amounts: any[]; + discountable: boolean; + discounts: any[]; + livemode: boolean; + metadata: Metadata; + period: Period; + plan: Plan; + price: Price; + proration: boolean; + proration_details: ProrationDetails; + quantity: number; + subscription: string; + subscription_item: string; + tax_amounts: any[]; + tax_rates: any[]; + type: string; + unit_amount_excluding_tax: string; +} + +export interface Period { + end: number; + start: number; +} + +export interface Plan { + id: string; + object: string; + active: boolean; + aggregate_usage: any; + amount: number; + amount_decimal: string; + billing_scheme: string; + created: number; + currency: string; + interval: string; + interval_count: number; + livemode: boolean; + metadata: Metadata; + nickname: any; + product: string; + tiers_mode: any; + transform_usage: any; + trial_period_days: any; + usage_type: string; +} + +export interface Price { + id: string; + object: string; + active: boolean; + billing_scheme: string; + created: number; + currency: string; + custom_unit_amount: any; + livemode: boolean; + lookup_key: any; + metadata: Metadata; + nickname: any; + product: string; + recurring: Recurring; + tax_behavior: string; + tiers_mode: any; + transform_quantity: any; + type: string; + unit_amount: number; + unit_amount_decimal: string; +} + +export interface Recurring { + aggregate_usage: any; + interval: string; + interval_count: number; + trial_period_days: any; + usage_type: string; +} + +export interface ProrationDetails { + credited_items: any; +} + +export interface PaymentSettings { + default_mandate: any; + payment_method_options: any; + payment_method_types: any; +} + +export interface StatusTransitions { + finalized_at: number; + marked_uncollectible_at: any; + paid_at: number; + voided_at: any; +} + +export interface SubscriptionDetails { + metadata: Metadata; +} diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/types/stripe-payment-intent.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/types/stripe-payment-intent.ts new file mode 100644 index 00000000..a82d232e --- /dev/null +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/types/stripe-payment-intent.ts @@ -0,0 +1,186 @@ +import { Metadata } from './stripe.common'; + +export interface StripePaymentIntent { + id: string; + object: string; + amount: number; + amount_capturable: number; + amount_details: AmountDetails; + amount_received: number; + application: any; + application_fee_amount: any; + automatic_payment_methods: any; + canceled_at: any; + cancellation_reason: any; + capture_method: string; + charges: Charges; + client_secret: string; + confirmation_method: string; + created: number; + currency: string; + customer: string; + description: any; + invoice: any; + last_payment_error: any; + latest_charge: string; + livemode: boolean; + metadata: Metadata; + next_action: any; + on_behalf_of: any; + payment_method: string; + payment_method_options: PaymentMethodOptions; + payment_method_types: string[]; + processing: any; + receipt_email: string; + review: any; + setup_future_usage: string; + shipping: any; + source: any; + statement_descriptor: any; + statement_descriptor_suffix: any; + status: string; + transfer_data: any; + transfer_group: any; +} + +export interface AmountDetails { + tip: Tip; +} + +export interface Tip {} + +export interface Charges { + object: string; + data: Daum[]; + has_more: boolean; + total_count: number; + url: string; +} + +export interface Daum { + id: string; + object: string; + amount: number; + amount_captured: number; + amount_refunded: number; + application: any; + application_fee: any; + application_fee_amount: any; + balance_transaction: string; + billing_details: BillingDetails; + calculated_statement_descriptor: string; + captured: boolean; + created: number; + currency: string; + customer: string; + description: any; + destination: any; + dispute: any; + disputed: boolean; + failure_balance_transaction: any; + failure_code: any; + failure_message: any; + fraud_details: FraudDetails; + invoice: any; + livemode: boolean; + metadata: Metadata; + on_behalf_of: any; + order: any; + outcome: Outcome; + paid: boolean; + payment_intent: string; + payment_method: string; + payment_method_details: PaymentMethodDetails; + receipt_email: string; + receipt_number: any; + receipt_url: string; + refunded: boolean; + refunds: Refunds; + review: any; + shipping: any; + source: any; + source_transfer: any; + statement_descriptor: any; + statement_descriptor_suffix: any; + status: string; + transfer_data: any; + transfer_group: any; +} + +export interface BillingDetails { + address: Address; + email: any; + name: any; + phone: any; +} + +export interface Address { + city: any; + country: string; + line1: any; + line2: any; + postal_code: any; + state: any; +} + +export interface FraudDetails {} + +export interface Outcome { + network_status: string; + reason: any; + risk_level: string; + risk_score: number; + seller_message: string; + type: string; +} + +export interface PaymentMethodDetails { + card: Card; + type: string; +} + +export interface Card { + brand: string; + checks: Checks; + country: string; + exp_month: number; + exp_year: number; + fingerprint: string; + funding: string; + installments: any; + last4: string; + mandate: any; + network: string; + network_token: NetworkToken; + three_d_secure: any; + wallet: any; +} + +export interface Checks { + address_line1_check: any; + address_postal_code_check: any; + cvc_check: string; +} + +export interface NetworkToken { + used: boolean; +} + +export interface Refunds { + object: string; + data: any[]; + has_more: boolean; + total_count: number; + url: string; +} + +export interface PaymentMethodOptions { + card: Card2; +} + +export interface Card2 { + installments: any; + mandate_options: any; + network: any; + request_three_d_secure: string; +} diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/types/stripe.common.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/types/stripe.common.ts new file mode 100644 index 00000000..1555efa5 --- /dev/null +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/types/stripe.common.ts @@ -0,0 +1,30 @@ +import { StripeInvoice } from './stripe-invoice'; +import { StripePaymentIntent } from './stripe-payment-intent'; + +export interface Metadata { + orderCode: string; + channelToken: string; + paymentMethodCode: string; + amount: number; +} + +export interface Data { + object: StripeInvoice | StripePaymentIntent; +} + +export interface Request { + id?: any; + idempotency_key?: any; +} + +export interface IncomingStripeWebhook { + id: string; + object: string; + api_version: string; + created: number; + data: Data; + livemode: boolean; + pending_webhooks: number; + request: Request; + type: string; +} diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/util.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/util.ts new file mode 100644 index 00000000..9a0ff1ed --- /dev/null +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/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); + } \ No newline at end of file diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/custom-fields.d.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/custom-fields.d.ts new file mode 100644 index 00000000..dcb09cb4 --- /dev/null +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/custom-fields.d.ts @@ -0,0 +1,19 @@ +// types.ts + +// 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 { CustomCustomerFields, CustomOrderLineFields } from '@vendure/core/dist/entity/custom-entity-fields'; + +declare module '@vendure/core/dist/entity/custom-entity-fields' { + interface CustomCustomerFields { + stripeSubscriptionCustomerId?: string; + } + interface CustomOrderLineFields { + subscriptionIds?: string[]; + /** + * Unique hash to separate order lines + */ + subscriptionHash?: string; + } +} \ No newline at end of file diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/custom-fields.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/custom-fields.ts new file mode 100644 index 00000000..84025c37 --- /dev/null +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/custom-fields.ts @@ -0,0 +1,59 @@ +import { + CustomFieldConfig, + LanguageCode +} from '@vendure/core'; + +export const customerCustomFields: CustomFieldConfig[] = [ + { + name: 'stripeSubscriptionCustomerId', + label: [ + { + languageCode: LanguageCode.en, + value: 'Stripe Customer ID', + }, + ], + type: 'string', + public: false, + nullable: true, + ui: { tab: 'Subscription' }, + }, +]; + +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-v2/vendure-config/has-stripe-subscription-products-payment-checker.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/has-stripe-subscription-products-payment-checker.ts new file mode 100644 index 00000000..852ea26d --- /dev/null +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/has-stripe-subscription-products-payment-checker.ts @@ -0,0 +1,30 @@ +import { + Injector, + LanguageCode, + Order, + PaymentMethodEligibilityChecker, +} from '@vendure/core'; +import { StripeSubscriptionService } from '../stripe-subscription.service'; + +let stripeSubscriptionService: StripeSubscriptionService; + +export const hasStripeSubscriptionProductsPaymentChecker = + new PaymentMethodEligibilityChecker({ + code: 'has-stripe-subscription-products-checker', + description: [ + { + languageCode: LanguageCode.en, + value: 'Checks if the order has Subscription products.', + }, + ], + args: {}, + init: (injector: Injector) => { + stripeSubscriptionService = injector.get(StripeSubscriptionService); + }, + check: (ctx, order, args) => { + if (stripeSubscriptionService?.hasSubscriptionProducts(ctx, order)) { + return true; + } + return false; + }, + }); diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/stripe-subscription.handler.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/stripe-subscription.handler.ts new file mode 100644 index 00000000..62d401f1 --- /dev/null +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/stripe-subscription.handler.ts @@ -0,0 +1,123 @@ +import { + CreatePaymentResult, + CreateRefundResult, + Injector, + LanguageCode, + Logger, + PaymentMethodHandler, + SettlePaymentResult, +} from '@vendure/core'; +import { loggerCtx } from '../../constants'; +import { StripeSubscriptionService } from '../stripe-subscription.service'; +import { printMoney } from '../util'; + +let service: StripeSubscriptionService; +export const stripeSubscriptionHandler = new PaymentMethodHandler({ + code: 'stripe-subscription', + + description: [ + { + languageCode: LanguageCode.en, + value: 'Use a Stripe Subscription as payment', + }, + ], + + args: { + apiKey: { + type: 'string', + label: [{ languageCode: LanguageCode.en, value: 'Stripe API key' }], + ui: { component: 'password-form-input' }, + }, + publishableKey: { + type: 'string', + required: false, + label: [ + { languageCode: LanguageCode.en, value: 'Stripe publishable key' }, + ], + description: [ + { + languageCode: LanguageCode.en, + value: + 'You can retrieve this via the "eligiblePaymentMethods.stripeSubscriptionPublishableKey" query in the shop api', + }, + ], + }, + webhookSecret: { + type: 'string', + label: [ + { + languageCode: LanguageCode.en, + value: 'Webhook secret', + }, + ], + description: [ + { + languageCode: LanguageCode.en, + value: + 'Secret to validate incoming webhooks. Get this from your Stripe dashboard', + }, + ], + ui: { component: 'password-form-input' }, + }, + }, + + init(injector: Injector) { + service = injector.get(StripeSubscriptionService); + }, + + async createPayment( + ctx, + order, + amount, + _, + metadata + ): Promise { + // Payment is already settled in Stripe by the time the webhook in stripe.controller.ts + // adds the payment to the order + if (ctx.apiType !== 'admin') { + throw Error(`CreatePayment is not allowed for apiType '${ctx.apiType}'`); + } + return { + amount: metadata.amount, + state: 'Settled', + transactionId: metadata.setupIntentId, + metadata, + }; + }, + settlePayment(): SettlePaymentResult { + // Payments will be settled via webhook + return { + success: true, + }; + }, + + async createRefund( + ctx, + input, + amount, + order, + payment, + args + ): Promise { + const { stripeClient } = await service.getStripeContext(ctx); + const refund = await stripeClient.refunds.create({ + payment_intent: payment.transactionId, + amount, + }); + Logger.info( + `Refund of ${printMoney(amount)} created for payment ${ + payment.transactionId + } for order ${order.id}`, + loggerCtx + ); + await service.logHistoryEntry( + ctx, + order.id, + `Created refund of ${printMoney(amount)}` + ); + return { + state: 'Settled', + metadata: refund, + }; + }, +}); 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 8c74f07b..37d43e74 100644 --- a/packages/vendure-plugin-stripe-subscription/src/stripe-subscription.plugin.ts +++ b/packages/vendure-plugin-stripe-subscription/src/stripe-subscription.plugin.ts @@ -1,9 +1,14 @@ import { PluginCommonModule, VendurePlugin } from '@vendure/core'; import { PLUGIN_INIT_OPTIONS } from './constants'; -import { SubscriptionStrategy } from './api-v2/subscription-strategy'; +import { SubscriptionStrategy } from './api-v2/strategy/subscription-strategy'; import { shopSchemaExtensions } from './api-v2/graphql-schema'; import { createRawBodyMiddleWare } from '../../util/src/raw-body'; -import { DefaultSubscriptionStrategy } from './api-v2/default-subscription-strategy'; +import { DefaultSubscriptionStrategy } from './api-v2/strategy/default-subscription-strategy'; +import path from 'path'; +import { AdminUiExtension } from '@vendure/ui-devkit/compiler'; +import { customerCustomFields, orderLineCustomFields } from './api-v2/vendure-config/custom-fields'; +import { stripeSubscriptionHandler } from './api-v2/vendure-config/stripe-subscription.handler'; +import { hasStripeSubscriptionProductsPaymentChecker } from './api-v2/vendure-config/has-stripe-subscription-products-payment-checker'; export interface StripeSubscriptionPluginOptions { /** @@ -27,11 +32,13 @@ export interface StripeSubscriptionPluginOptions { }, ], configuration: (config) => { - // FIXME config.paymentOptions.paymentMethodHandlers.push(stripeSubscriptionHandler); - // FIXME config.paymentOptions.paymentMethodEligibilityCheckers = [ - // ...(config.paymentOptions.paymentMethodEligibilityCheckers ?? []), - // hasStripeSubscriptionProductsPaymentChecker, - // ]; + config.paymentOptions.paymentMethodHandlers.push(stripeSubscriptionHandler); + config.paymentOptions.paymentMethodEligibilityCheckers = [ + ...(config.paymentOptions.paymentMethodEligibilityCheckers ?? []), + hasStripeSubscriptionProductsPaymentChecker, + ]; + config.customFields.Customer.push(...customerCustomFields); + config.customFields.OrderLine.push(...orderLineCustomFields); config.apiOptions.middleware.push( createRawBodyMiddleWare('/stripe-subscription*') ); @@ -54,4 +61,15 @@ export class StripeSubscriptionPlugin { }; return StripeSubscriptionPlugin; } + + static ui: AdminUiExtension = { + extensionPath: path.join(__dirname, 'ui'), + ngModules: [ + { + type: 'shared', + ngModuleFileName: 'stripe-subscription-shared.module.ts', + ngModuleName: 'StripeSubscriptionSharedModule', + }, + ], + }; } 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..78487058 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 @@ -12,34 +12,10 @@ import { HistoryEntryComponent } from './history-entry.component'; 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 {} +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 {} From 2bd32e626913bab309f6401cc984a32962ad8f25 Mon Sep 17 00:00:00 2001 From: Martijn Date: Sat, 30 Sep 2023 16:18:16 +0200 Subject: [PATCH 05/23] feat(stripe-subscription): wip --- .../src/api-v2/stripe-subscription.service.ts | 155 +++++++++--------- .../src/api-v2/stripe.client.ts | 12 +- .../api-v2/vendure-config/custom-fields.d.ts | 19 --- .../api-v2/vendure-config/custom-fields.ts | 16 -- .../src/stripe-subscription.plugin.ts | 3 +- .../src/ui/history-entry.component.ts | 3 +- 6 files changed, 80 insertions(+), 128 deletions(-) delete mode 100644 packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/custom-fields.d.ts diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts index 1619a86c..6f9bfe16 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts @@ -1,4 +1,5 @@ -import { Inject, 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, ChannelService, @@ -7,7 +8,7 @@ import { ErrorResult, EventBus, HistoryService, - ID, JobQueue, + ID, Injector, JobQueue, JobQueueService, LanguageCode, Logger, Order, @@ -28,17 +29,16 @@ 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 { - StripeSubscriptionPricing -} from '../ui/generated/graphql'; -import { StripeSubscriptionIntent, StripeSubscriptionIntentType } from './generated/graphql'; -import { SubscriptionStrategy } from './strategy/subscription-strategy'; -import { stripeSubscriptionHandler } from './payment-method/stripe-subscription.handler'; +import { StripeSubscriptionBothPaymentTypes, StripeSubscriptionIntent, StripeSubscriptionIntentType } from './generated/graphql'; +import { Subscription, SubscriptionStrategy } from './strategy/subscription-strategy'; import { StripeClient } from './stripe.client'; -import './vendure-config/custom-fields.d.ts' +import './vendure-config/custom-fields-types.d.ts' import { StripeInvoice } from './types/stripe-invoice'; import { printMoney } from './util'; +import { StripeSubscriptionPluginOptions } from '../stripe-subscription.plugin'; +import * as types from './vendure-config/custom-fields-types'; +import { stripeSubscriptionHandler } from './vendure-config/stripe-subscription.handler'; +import { StripePaymentIntent } from './types/stripe-payment-intent'; export interface StripeContext { paymentMethod: PaymentMethod; @@ -72,7 +72,7 @@ export class StripeSubscriptionService { private historyService: HistoryService, private eventBus: EventBus, private jobQueueService: JobQueueService, - private customerService: CustomerService, + private moduleRef: ModuleRef, private connection: TransactionalConnection, @Inject(PLUGIN_INIT_OPTIONS) private options: StripeSubscriptionPluginOptions ) { @@ -148,7 +148,7 @@ export class StripeSubscriptionService { .subscribe(async (event) => { const cancelOrReleaseEvents = event.stockMovements as (Cancellation | Release)[]; const orderLinesWithSubscriptions = cancelOrReleaseEvents - // Filter out non-sub orderlines + // Filter out non-sub orderlines .filter((event) => (event.orderLine.customFields).subscriptionIds); await Promise.all( // Push jobs @@ -167,11 +167,11 @@ export class StripeSubscriptionService { 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`); } @@ -241,35 +241,30 @@ export class StripeSubscriptionService { } async createIntent(ctx: RequestContext): Promise { - let order = (await this.activeOrderService.getActiveOrder( + let order = await this.activeOrderService.getActiveOrder( ctx, undefined - )) as OrderWithSubscriptionFields; + ); 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; + // This means a one time payment is needed } await this.entityHydrator.hydrate(ctx, order, { relations: ['customer', 'shippingLines', 'lines.productVariant'], }); 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 @@ -281,55 +276,65 @@ 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 with handler code '${stripeSubscriptionHandler.code}'` ); } - const stripeCustomer = await stripeClient.getOrCreateCustomer( - order.customer - ); - this.customerService - .update(ctx, { - id: order.customer.id, - customFields: { - stripeSubscriptionCustomerId: stripeCustomer.id, + const stripeCustomer = await stripeClient.getOrCreateCustomer( order.customer ); + const stripePaymentMethods = ['card'] // TODO make configurable per channel + const injector = new Injector(this.moduleRef); + const subscriptions = await Promise.all(order.lines.map((line) => this.strategy.defineSubscription(ctx, injector, line))); + const hasRecurringPayments = subscriptions.some((s) => (s as StripeSubscriptionBothPaymentTypes).recurring.amount > 0); + const hasOneTimePayments = subscriptions.some((s) => (s as StripeSubscriptionBothPaymentTypes).amountDueNow > 0); + let intent: Stripe.PaymentIntent | Stripe.SetupIntent; + if (hasRecurringPayments && hasOneTimePayments) { + // Create PaymentIntent + off_session, because we have both one-time and recurring payments + intent = await stripeClient.paymentIntents.create({ + customer: stripeCustomer.id, + payment_method_types: stripePaymentMethods, + setup_future_usage: 'off_session', + amount: order.totalWithTax, + currency: order.currencyCode, + metadata: { + orderCode: order.code, + channelToken: ctx.channel.token, + amount: order.totalWithTax, }, - }) - .catch((err) => - Logger.error( - `Failed to update stripeCustomerId ${stripeCustomer.id} for ${order.customer.emailAddress}`, - loggerCtx, - err - ) - ); - // FIXME - const hasSubscriptionProducts = order.lines.some( - (l) => l.productVariant.customFields.subscriptionSchedule - ); - // FIXME create Setup or PaymentIntent - 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, + }); + } else if (hasOneTimePayments) { + // Create PaymentIntent, because we only have one-time payments + intent = await stripeClient.paymentIntents.create({ + customer: stripeCustomer.id, + payment_method_types: stripePaymentMethods, + setup_future_usage: 'on_session', amount: order.totalWithTax, - }, - }); - - // FIXME - const intentType = StripeSubscriptionIntentType.SetupIntent // FIXME + 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, + }, + }); + } + const intentType = intent.object === 'payment_intent' ? StripeSubscriptionIntentType.PaymentIntent : StripeSubscriptionIntentType.SetupIntent; + if (!intent.client_secret) { + throw Error(`No client_secret found in ${intentType} response, something went wrong!`); + } Logger.info( `Created ${intentType} '${intent.id}' for order ${order.code}`, loggerCtx ); - if (!intent.client_secret) { - throw Error(`No client_secret found in ${intentType} response, something went wrong!`); - } return { clientSecret: intent.client_secret, intentType @@ -448,11 +453,11 @@ 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}`); } @@ -548,22 +553,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, @@ -574,7 +569,7 @@ export class StripeSubscriptionService { valid: !error, error: prettifiedError, subscriptionId, - pricing: prettifierPricing, + subscription }, }, false diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe.client.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe.client.ts index bac0786b..2baacbf5 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe.client.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe.client.ts @@ -1,5 +1,5 @@ +import { Customer } from '@vendure/core'; import Stripe from 'stripe'; -import { CustomerWithSubscriptionFields } from './vendure-config/custom-fields'; interface SubscriptionInput { customerId: string; @@ -29,16 +29,8 @@ export class StripeClient extends Stripe { } async getOrCreateCustomer( - customer: CustomerWithSubscriptionFields + customer: Customer ): Promise { - if (customer.customFields?.stripeSubscriptionCustomerId) { - const stripeCustomer = await this.customers.retrieve( - customer.customFields.stripeSubscriptionCustomerId - ); - if (stripeCustomer && !stripeCustomer.deleted) { - return stripeCustomer as Stripe.Customer; - } - } const stripeCustomers = await this.customers.list({ email: customer.emailAddress, }); diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/custom-fields.d.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/custom-fields.d.ts deleted file mode 100644 index dcb09cb4..00000000 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/custom-fields.d.ts +++ /dev/null @@ -1,19 +0,0 @@ -// types.ts - -// 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 { CustomCustomerFields, CustomOrderLineFields } from '@vendure/core/dist/entity/custom-entity-fields'; - -declare module '@vendure/core/dist/entity/custom-entity-fields' { - interface CustomCustomerFields { - stripeSubscriptionCustomerId?: string; - } - interface CustomOrderLineFields { - subscriptionIds?: string[]; - /** - * Unique hash to separate order lines - */ - subscriptionHash?: string; - } -} \ No newline at end of file diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/custom-fields.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/custom-fields.ts index 84025c37..4d7cf051 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/custom-fields.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/custom-fields.ts @@ -3,22 +3,6 @@ import { LanguageCode } from '@vendure/core'; -export const customerCustomFields: CustomFieldConfig[] = [ - { - name: 'stripeSubscriptionCustomerId', - label: [ - { - languageCode: LanguageCode.en, - value: 'Stripe Customer ID', - }, - ], - type: 'string', - public: false, - nullable: true, - ui: { tab: 'Subscription' }, - }, -]; - export const orderLineCustomFields: CustomFieldConfig[] = [ { name: 'subscriptionIds', 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 37d43e74..530a12a2 100644 --- a/packages/vendure-plugin-stripe-subscription/src/stripe-subscription.plugin.ts +++ b/packages/vendure-plugin-stripe-subscription/src/stripe-subscription.plugin.ts @@ -6,7 +6,7 @@ import { createRawBodyMiddleWare } from '../../util/src/raw-body'; import { DefaultSubscriptionStrategy } from './api-v2/strategy/default-subscription-strategy'; import path from 'path'; import { AdminUiExtension } from '@vendure/ui-devkit/compiler'; -import { customerCustomFields, orderLineCustomFields } from './api-v2/vendure-config/custom-fields'; +import { orderLineCustomFields } from './api-v2/vendure-config/custom-fields'; import { stripeSubscriptionHandler } from './api-v2/vendure-config/stripe-subscription.handler'; import { hasStripeSubscriptionProductsPaymentChecker } from './api-v2/vendure-config/has-stripe-subscription-products-payment-checker'; @@ -37,7 +37,6 @@ export interface StripeSubscriptionPluginOptions { ...(config.paymentOptions.paymentMethodEligibilityCheckers ?? []), hasStripeSubscriptionProductsPaymentChecker, ]; - config.customFields.Customer.push(...customerCustomFields); config.customFields.OrderLine.push(...orderLineCustomFields); config.apiOptions.middleware.push( createRawBodyMiddleWare('/stripe-subscription*') 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..4dec4309 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 @@ -49,6 +49,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; } } From 2d8920fca540a352de2d4ce05719831917c496e6 Mon Sep 17 00:00:00 2001 From: Martijn Date: Sun, 1 Oct 2023 11:43:23 +0200 Subject: [PATCH 06/23] feat(stripe-subscription): strategy interface definition changed --- .../codegen.yml | 1 + .../src/api-v2/graphql-schema.ts | 7 +- .../strategy/default-subscription-strategy.ts | 8 +- .../api-v2/strategy/subscription-strategy.ts | 18 +- .../api-v2/stripe-subscription.resolver.ts | 93 ++++++++++ .../src/api-v2/stripe-subscription.service.ts | 165 +++++++++++++----- .../src/api-v2/stripe.client.ts | 6 +- .../subscription-order-item-calculation.ts | 53 ++++++ .../src/api-v2/util.ts | 4 +- .../vendure-config/custom-fields-types.d.ts | 14 ++ .../api-v2/vendure-config/custom-fields.ts | 5 +- .../src/index.ts | 11 +- .../src/stripe-subscription.plugin.ts | 5 +- .../ui/stripe-subscription-shared.module.ts | 2 +- 14 files changed, 324 insertions(+), 68 deletions(-) create mode 100644 packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.resolver.ts create mode 100644 packages/vendure-plugin-stripe-subscription/src/api-v2/subscription-order-item-calculation.ts create mode 100644 packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/custom-fields-types.d.ts diff --git a/packages/vendure-plugin-stripe-subscription/codegen.yml b/packages/vendure-plugin-stripe-subscription/codegen.yml index 7e54214e..dd12d6a1 100644 --- a/packages/vendure-plugin-stripe-subscription/codegen.yml +++ b/packages/vendure-plugin-stripe-subscription/codegen.yml @@ -6,6 +6,7 @@ generates: - typescript-operations - typed-document-node config: + enumsAsTypes: true avoidOptionals: false scalars: DateTime: Date diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/graphql-schema.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/graphql-schema.ts index fc1adcdf..957f122b 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/graphql-schema.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/graphql-schema.ts @@ -63,10 +63,13 @@ export const shopSchemaExtensions = gql` productVariantId: ID! customInputs: JSON ): StripeSubscription! - previewStripeSubscriptionForProduct(productId: ID!): [StripeSubscription!]! + previewStripeSubscriptionForProduct( + productId: ID! + customInputs: JSON + ): [StripeSubscription!]! } extend type Mutation { - createStripeSubscriptionIntent: String! + createStripeSubscriptionIntent: StripeSubscriptionIntent! } `; diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/default-subscription-strategy.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/default-subscription-strategy.ts index 1de055c5..e76da598 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/default-subscription-strategy.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/default-subscription-strategy.ts @@ -15,12 +15,14 @@ export class DefaultSubscriptionStrategy implements SubscriptionStrategy { defineSubscription( ctx: RequestContext, injector: Injector, - orderLine: OrderLine + productVariant: ProductVariant, + orderLineCustomFields: { [key: string]: any }, + quantity: number ): Subscription { - return this.getSubscriptionForVariant(orderLine.productVariant); + return this.getSubscriptionForVariant(productVariant); } - isSubscription(ctx: RequestContext, orderLineWithVariant: OrderLine): boolean { + isSubscription(ctx: RequestContext, variant: ProductVariant): boolean { // This example treats all products as subscriptions return true; } diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/subscription-strategy.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/subscription-strategy.ts index f525ad64..1de71643 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/subscription-strategy.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/subscription-strategy.ts @@ -1,8 +1,8 @@ import { - OrderLine, - RequestContext, Injector, + OrderLine, ProductVariant, + RequestContext, } from '@vendure/core'; /** @@ -30,25 +30,27 @@ export interface RecurringPayment { } export interface SubscriptionStrategy { - /** - * Determines if the given orderline should be treated as a subscription, or as a regular product + * Determines if the given variant should be treated as a subscription, or as a regular product */ - isSubscription(ctx: RequestContext, orderLineWithVariant: OrderLine): boolean; + isSubscription(ctx: RequestContext, variant: ProductVariant): boolean; /** - * Define a subscription based on the given order line. + * Define a subscription based on the given order line fields. * This is executed when an item is being added to cart */ defineSubscription( ctx: RequestContext, injector: Injector, - orderLine: OrderLine + productVariant: ProductVariant, + orderLineCustomFields: { [key: string]: any }, + quantity: number ): 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, to, for example, preview the subscription with a custom start Date + * 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( diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.resolver.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.resolver.ts new file mode 100644 index 00000000..bd7d5fec --- /dev/null +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.resolver.ts @@ -0,0 +1,93 @@ +import { + Args, + Mutation, + Parent, + Query, + ResolveField, + Resolver, +} from '@nestjs/graphql'; +import { PaymentMethodQuote } from '@vendure/common/lib/generated-shop-types'; +import { + Allow, + Ctx, + ID, + OrderService, + PaymentMethodService, + Permission, + ProductService, + RequestContext, + UserInputError, +} from '@vendure/core'; +import { Request } from 'express'; +import { + QueryPreviewStripeSubscriptionArgs, + QueryPreviewStripeSubscriptionForProductArgs, + StripeSubscription, + StripeSubscriptionIntent, +} from './generated/graphql'; +import { StripeSubscriptionService } from './stripe-subscription.service'; + +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.createIntent(ctx); + } + + @Query() + async previewStripeSubscription( + @Ctx() ctx: RequestContext, + @Args() + { productVariantId, customInputs }: QueryPreviewStripeSubscriptionArgs + ): Promise { + return this.stripeSubscriptionService.previewSubscription( + ctx, + productVariantId, + customInputs + ); + } + + @Query() + async previewStripeSubscriptionForProduct( + @Ctx() ctx: RequestContext, + @Args() + { productId, customInputs }: QueryPreviewStripeSubscriptionForProductArgs + ): 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-v2/stripe-subscription.service.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts index 6f9bfe16..3b41115d 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts @@ -1,26 +1,35 @@ -import { Inject, 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, ChannelService, + ActiveOrderService, + ChannelService, CustomerService, EntityHydrator, ErrorResult, EventBus, HistoryService, - ID, Injector, JobQueue, + ID, + Injector, + JobQueue, JobQueueService, - LanguageCode, Logger, + LanguageCode, + Logger, Order, OrderLine, OrderLineEvent, OrderService, - OrderStateTransitionError, PaymentMethod, - PaymentMethodService, RequestContext, + OrderStateTransitionError, + PaymentMethod, + PaymentMethodService, + ProductService, + ProductVariant, + ProductVariantService, + RequestContext, SerializedRequestContext, StockMovementEvent, TransactionalConnection, - UserInputError + UserInputError, } from '@vendure/core'; import { Cancellation } from '@vendure/core/dist/entity/stock-movement/cancellation.entity'; import { Release } from '@vendure/core/dist/entity/stock-movement/release.entity'; @@ -29,10 +38,17 @@ import { Request } from 'express'; import { filter } from 'rxjs/operators'; import Stripe from 'stripe'; import { loggerCtx, PLUGIN_INIT_OPTIONS } from '../constants'; -import { StripeSubscriptionBothPaymentTypes, StripeSubscriptionIntent, StripeSubscriptionIntentType } from './generated/graphql'; -import { Subscription, SubscriptionStrategy } from './strategy/subscription-strategy'; +import { + StripeSubscriptionBothPaymentTypes, + StripeSubscriptionIntent, + StripeSubscriptionIntentType, +} from './generated/graphql'; +import { + Subscription, + SubscriptionStrategy, +} from './strategy/subscription-strategy'; import { StripeClient } from './stripe.client'; -import './vendure-config/custom-fields-types.d.ts' +import './vendure-config/custom-fields-types.d.ts'; import { StripeInvoice } from './types/stripe-invoice'; import { printMoney } from './util'; import { StripeSubscriptionPluginOptions } from '../stripe-subscription.plugin'; @@ -74,9 +90,12 @@ export class StripeSubscriptionService { private jobQueueService: JobQueueService, private moduleRef: ModuleRef, private connection: TransactionalConnection, - @Inject(PLUGIN_INIT_OPTIONS) private options: StripeSubscriptionPluginOptions + private productVariantService: ProductVariantService, + private productService: ProductService, + @Inject(PLUGIN_INIT_OPTIONS) + private options: StripeSubscriptionPluginOptions ) { - this.strategy = this.options.subscriptionStrategy! + this.strategy = this.options.subscriptionStrategy!; } private jobQueue!: JobQueue; @@ -125,7 +144,7 @@ export class StripeSubscriptionService { this.eventBus.ofType(OrderLineEvent).subscribe(async (event) => { if ( event.type === 'created' && - this.strategy.isSubscription(event.ctx, event.orderLine) + this.strategy.isSubscription(event.ctx, event.orderLine.productVariant) ) { await this.connection .getRepository(event.ctx, OrderLine) @@ -146,10 +165,13 @@ export class StripeSubscriptionService { ) ) .subscribe(async (event) => { - const cancelOrReleaseEvents = event.stockMovements as (Cancellation | Release)[]; + const cancelOrReleaseEvents = event.stockMovements as ( + | Cancellation + | Release + )[]; const orderLinesWithSubscriptions = cancelOrReleaseEvents // Filter out non-sub orderlines - .filter((event) => (event.orderLine.customFields).subscriptionIds); + .filter((event) => event.orderLine.customFields.subscriptionIds); await Promise.all( // Push jobs orderLinesWithSubscriptions.map((line) => @@ -163,6 +185,48 @@ export class StripeSubscriptionService { }); } + 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); + return this.strategy.previewSubscription( + ctx, + injector, + variant, + customInputs + ); + } + + async previewSubscriptionForProduct( + ctx: RequestContext, + productId: ID, + customInputs?: any + ): Promise { + const product = await this.productService.findOne(ctx, productId, [ + 'variants', + ]); + if (!product) { + throw new UserInputError(`No product with id '${product}' found`); + } + const injector = new Injector(this.moduleRef); + return Promise.all( + product.variants.map((variant) => + this.strategy.previewSubscription(ctx, injector, variant, customInputs) + ) + ); + } + async cancelSubscriptionForOrderLine( ctx: RequestContext, orderLineId: ID @@ -241,10 +305,7 @@ export class StripeSubscriptionService { } async createIntent(ctx: RequestContext): Promise { - let order = await this.activeOrderService.getActiveOrder( - ctx, - undefined - ); + let order = await this.activeOrderService.getActiveOrder(ctx, undefined); if (!order) { throw new UserInputError('No active order for session'); } @@ -279,12 +340,17 @@ export class StripeSubscriptionService { `No eligible payment method found with handler code '${stripeSubscriptionHandler.code}'` ); } - const stripeCustomer = await stripeClient.getOrCreateCustomer( order.customer ); - const stripePaymentMethods = ['card'] // TODO make configurable per channel - const injector = new Injector(this.moduleRef); - const subscriptions = await Promise.all(order.lines.map((line) => this.strategy.defineSubscription(ctx, injector, line))); - const hasRecurringPayments = subscriptions.some((s) => (s as StripeSubscriptionBothPaymentTypes).recurring.amount > 0); - const hasOneTimePayments = subscriptions.some((s) => (s as StripeSubscriptionBothPaymentTypes).amountDueNow > 0); + const stripeCustomer = await stripeClient.getOrCreateCustomer( + order.customer + ); + const stripePaymentMethods = ['card']; // TODO make configurable per channel + const subscriptions = await this.defineSubscriptions(ctx, order); + const hasRecurringPayments = subscriptions.some( + (s) => (s as StripeSubscriptionBothPaymentTypes).recurring.amount > 0 + ); + const hasOneTimePayments = subscriptions.some( + (s) => (s as StripeSubscriptionBothPaymentTypes).amountDueNow > 0 + ); let intent: Stripe.PaymentIntent | Stripe.SetupIntent; if (hasRecurringPayments && hasOneTimePayments) { // Create PaymentIntent + off_session, because we have both one-time and recurring payments @@ -327,9 +393,12 @@ export class StripeSubscriptionService { }, }); } - const intentType = intent.object === 'payment_intent' ? StripeSubscriptionIntentType.PaymentIntent : StripeSubscriptionIntentType.SetupIntent; + const intentType = + intent.object === 'payment_intent' ? 'PaymentIntent' : 'SetupIntent'; if (!intent.client_secret) { - throw Error(`No client_secret found in ${intentType} response, something went wrong!`); + throw Error( + `No client_secret found in ${intentType} response, something went wrong!` + ); } Logger.info( `Created ${intentType} '${intent.id}' for order ${order.code}`, @@ -337,13 +406,35 @@ export class StripeSubscriptionService { ); return { clientSecret: intent.client_secret, - intentType - } + intentType, + }; + } + + /** + * This defines the actual subscriptions and prices for each order line, based on the configured strategy. + */ + async defineSubscriptions( + ctx: RequestContext, + order: Order + ): Promise { + const injector = new Injector(this.moduleRef); + const subscriptions = await Promise.all( + order.lines.map((line) => + this.strategy.defineSubscription( + ctx, + injector, + line.productVariant, + line.customFields, + line.quantity + ) + ) + ); + return subscriptions; } hasSubscriptionProducts(ctx: RequestContext, order: Order): boolean { - return order.lines.some( - (l) => this.strategy.isSubscription(ctx, l) + return order.lines.some((l) => + this.strategy.isSubscription(ctx, l.productVariant) ); } @@ -434,7 +525,8 @@ export class StripeSubscriptionService { ); if ((addPaymentToOrderResult as ErrorResult).errorCode) { throw Error( - `Error adding payment to order ${order.code}: ${(addPaymentToOrderResult as ErrorResult).message + `Error adding payment to order ${order.code}: ${ + (addPaymentToOrderResult as ErrorResult).message }` ); } @@ -457,17 +549,12 @@ export class StripeSubscriptionService { 'customer', 'lines', 'lines.productVariant', - ]) + ]); if (!order) { throw Error(`Cannot find order with code ${orderCode}`); } try { // FIXME do stuff here - - - - - // await this.saveSubscriptionIds(ctx, orderLine.id, createdSubscriptions); } catch (e: unknown) { await this.logHistoryEntry(ctx, order.id, '', e); @@ -569,7 +656,7 @@ export class StripeSubscriptionService { valid: !error, error: prettifiedError, subscriptionId, - subscription + subscription, }, }, false diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe.client.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe.client.ts index 2baacbf5..6c980cf2 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe.client.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe.client.ts @@ -28,9 +28,7 @@ export class StripeClient extends Stripe { super(apiKey, config); } - async getOrCreateCustomer( - customer: Customer - ): Promise { + async getOrCreateCustomer(customer: Customer): Promise { const stripeCustomers = await this.customers.list({ email: customer.emailAddress, }); @@ -74,7 +72,7 @@ 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. + // 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', diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/subscription-order-item-calculation.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/subscription-order-item-calculation.ts new file mode 100644 index 00000000..a5c397d1 --- /dev/null +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/subscription-order-item-calculation.ts @@ -0,0 +1,53 @@ +import { + Injector, + Order, + OrderItemPriceCalculationStrategy, + PriceCalculationResult, + ProductVariant, + RequestContext, +} from '@vendure/core'; +import { DefaultOrderItemPriceCalculationStrategy } from '@vendure/core/dist/config/order/default-order-item-price-calculation-strategy'; +import { CustomOrderLineFields } from '@vendure/core/dist/entity/custom-entity-fields'; +import { StripeSubscriptionPayment } from '../api/stripe-subscription-payment.entity'; +import { OneTimePayment } from './strategy/subscription-strategy'; +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); + } + + // @ts-ignore - Our strategy takes more arguments, so TS complains that it doesnt match the super.calculateUnitPrice + async calculateUnitPrice( + ctx: RequestContext, + productVariant: ProductVariant, + orderLineCustomFields: CustomOrderLineFields, + order: Order, + orderLineQuantity: number + ): Promise { + if (!subcriptionService) { + throw new Error('Subscription service not initialized'); + } + if (subcriptionService.strategy.isSubscription(ctx, productVariant)) { + const subscription = await subcriptionService.strategy.defineSubscription( + ctx, + injector, + productVariant, + orderLineCustomFields, + orderLineQuantity + ); + return { + priceIncludesTax: subscription.priceIncludesTax, + price: (subscription as OneTimePayment).amountDueNow ?? 0, + }; + } else { + return super.calculateUnitPrice(ctx, productVariant); + } + } +} diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/util.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/util.ts index 9a0ff1ed..65c0c46a 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/util.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/util.ts @@ -2,5 +2,5 @@ * Yes, it's real, this helper function prints money for you! */ export function printMoney(amount: number): string { - return (amount / 100).toFixed(2); - } \ No newline at end of file + return (amount / 100).toFixed(2); +} diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/custom-fields-types.d.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/custom-fields-types.d.ts new file mode 100644 index 00000000..d3a94dc9 --- /dev/null +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/custom-fields-types.d.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/api-v2/vendure-config/custom-fields.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/custom-fields.ts index 4d7cf051..a41ed036 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/custom-fields.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/custom-fields.ts @@ -1,7 +1,4 @@ -import { - CustomFieldConfig, - LanguageCode -} from '@vendure/core'; +import { CustomFieldConfig, LanguageCode } from '@vendure/core'; export const orderLineCustomFields: CustomFieldConfig[] = [ { diff --git a/packages/vendure-plugin-stripe-subscription/src/index.ts b/packages/vendure-plugin-stripe-subscription/src/index.ts index 62572608..ce353722 100644 --- a/packages/vendure-plugin-stripe-subscription/src/index.ts +++ b/packages/vendure-plugin-stripe-subscription/src/index.ts @@ -1,3 +1,8 @@ -export * from './ui/generated/graphql'; -export * from './api/default-subscription-strategy'; -export * from './api/subscription-strategy'; +export * from './api-v2/generated/graphql'; +export * from './api-v2/stripe-subscription.service'; +export * from './api-v2/strategy/subscription-strategy'; +export * from './api-v2/strategy/default-subscription-strategy'; +export * from './api-v2/vendure-config/has-stripe-subscription-products-payment-checker'; +export * from './api-v2/vendure-config/stripe-subscription.handler'; +export * from './api-v2/vendure-config/custom-fields-types'; +export * from './api-v2/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 530a12a2..13d8ddf0 100644 --- a/packages/vendure-plugin-stripe-subscription/src/stripe-subscription.plugin.ts +++ b/packages/vendure-plugin-stripe-subscription/src/stripe-subscription.plugin.ts @@ -9,6 +9,7 @@ import { AdminUiExtension } from '@vendure/ui-devkit/compiler'; import { orderLineCustomFields } from './api-v2/vendure-config/custom-fields'; import { stripeSubscriptionHandler } from './api-v2/vendure-config/stripe-subscription.handler'; import { hasStripeSubscriptionProductsPaymentChecker } from './api-v2/vendure-config/has-stripe-subscription-products-payment-checker'; +import { SubscriptionOrderItemCalculation } from './api-v2/subscription-order-item-calculation'; export interface StripeSubscriptionPluginOptions { /** @@ -41,8 +42,8 @@ export interface StripeSubscriptionPluginOptions { config.apiOptions.middleware.push( createRawBodyMiddleWare('/stripe-subscription*') ); - // FIXME config.orderOptions.orderItemPriceCalculationStrategy = - // new SubscriptionOrderItemCalculation(); + config.orderOptions.orderItemPriceCalculationStrategy = + new SubscriptionOrderItemCalculation(); return config; }, compatibility: '^2.0.0', 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 78487058..9e6f2b5d 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 @@ -18,4 +18,4 @@ import { HistoryEntryComponent } from './history-entry.component'; }), ], }) -export class StripeSubscriptionSharedModule { } +export class StripeSubscriptionSharedModule {} From 49c66d0479326f12295013678a6ef3d70d406b5d Mon Sep 17 00:00:00 2001 From: Martijn Date: Thu, 19 Oct 2023 20:52:20 +0200 Subject: [PATCH 07/23] feat(stripe-subscription): wip adding downpayment to the strategy interface --- .../README.md | 3 + .../src/api-v2/graphql-schema.ts | 22 +- .../strategy/default-subscription-strategy.ts | 1 + .../api-v2/strategy/subscription-strategy.ts | 22 +- .../api-v2/stripe-subscription.controller.ts | 109 ++++++++ .../api-v2/stripe-subscription.resolver.ts | 2 - .../src/api-v2/stripe-subscription.service.ts | 232 ++++++++++++++---- .../subscription-order-item-calculation.ts | 4 +- 8 files changed, 316 insertions(+), 79 deletions(-) create mode 100644 packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.controller.ts diff --git a/packages/vendure-plugin-stripe-subscription/README.md b/packages/vendure-plugin-stripe-subscription/README.md index 2d12f9e6..c0f925e9 100644 --- a/packages/vendure-plugin-stripe-subscription/README.md +++ b/packages/vendure-plugin-stripe-subscription/README.md @@ -1,3 +1,6 @@ +// TODO: Strategy explained. Failed invoice event +// No support for non-recurring payments. Use the built Vendure plugin for that. Only for recurring payments + # Vendure Stripe Subscription plugin ![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) diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/graphql-schema.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/graphql-schema.ts index 957f122b..bdd2d256 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/graphql-schema.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/graphql-schema.ts @@ -9,34 +9,20 @@ const _codegenAdditions = gql` `; export const shopSchemaExtensions = gql` - union StripeSubscription = - StripeSubscriptionOneTimePayment - | StripeSubscriptionRecurringPayment - | StripeSubscriptionBothPaymentTypes - enum StripeSubscriptionInterval { week month year } - type StripeSubscriptionBothPaymentTypes { - priceIncludesTax: Boolean! - amountDueNow: Int! - recurring: StripeSubscriptionRecurringPaymentDefinition! - } - - type StripeSubscriptionOneTimePayment { + type StripeSubscription { + name: String! + amountDueNow: Int priceIncludesTax: Boolean! - amountDueNow: Int! + recurring: StripeSubscriptionRecurringPayment! } type StripeSubscriptionRecurringPayment { - priceIncludesTax: Boolean! - recurring: StripeSubscriptionRecurringPaymentDefinition! - } - - type StripeSubscriptionRecurringPaymentDefinition { amount: Int! interval: StripeSubscriptionInterval! intervalCount: Int! diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/default-subscription-strategy.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/default-subscription-strategy.ts index e76da598..a15beb0b 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/default-subscription-strategy.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/default-subscription-strategy.ts @@ -40,6 +40,7 @@ export class DefaultSubscriptionStrategy implements SubscriptionStrategy { ): Subscription { const price = productVariant.listPrice; return { + name: `Subscription ${productVariant.name}`, priceIncludesTax: productVariant.listPriceIncludesTax, amountDueNow: price, recurring: { diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/subscription-strategy.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/subscription-strategy.ts index 1de71643..0106aa96 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/subscription-strategy.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/subscription-strategy.ts @@ -6,19 +6,14 @@ import { } from '@vendure/core'; /** - * Subscriptions can be created for One time payments, Recurring payments or a combination of the two + * Subscriptions can be created for Recurring payments or Recurring payments plus a one time payment */ -export type Subscription = - | OneTimePayment - | RecurringPayment - | (OneTimePayment & RecurringPayment); - -export interface OneTimePayment { - priceIncludesTax: boolean; - amountDueNow: number; -} - -export interface RecurringPayment { +export interface Subscription { + /** + * Name for displaying purposes + */ + name: string; + amountDueNow?: number; priceIncludesTax: boolean; recurring: { amount: number; @@ -37,7 +32,8 @@ export interface SubscriptionStrategy { /** * Define a subscription based on the given order line fields. - * This is executed when an item is being added to cart + * Executed after payment has been added to order, + * before subscriptions are created in Stripe */ defineSubscription( ctx: RequestContext, diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.controller.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.controller.ts new file mode 100644 index 00000000..96b076e0 --- /dev/null +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.controller.ts @@ -0,0 +1,109 @@ +import { Body, Controller, Headers, Inject, Post, Req } from '@nestjs/common'; +import { Logger, OrderService } from '@vendure/core'; +import { Request } from 'express'; +import { loggerCtx, PLUGIN_INIT_OPTIONS } from '../constants'; +import { StripeSubscriptionPluginOptions } from '../stripe-subscription.plugin'; +import { StripeSubscriptionService } from './stripe-subscription.service'; +import { StripeInvoice } from './types/stripe-invoice'; +import { StripePaymentIntent } from './types/stripe-payment-intent'; +import { IncomingStripeWebhook } from './types/stripe.common'; + +export type RequestWithRawBody = Request & { rawBody: any }; + +@Controller('stripe-subscriptions') +export class StripeSubscriptionController { + constructor( + private stripeSubscriptionService: StripeSubscriptionService, + private orderService: OrderService, + @Inject(PLUGIN_INIT_OPTIONS) + private options: StripeSubscriptionPluginOptions + ) {} + + @Post('webhook') + async webhook( + @Headers('stripe-signature') signature: string | undefined, + @Req() request: RequestWithRawBody, + @Body() body: IncomingStripeWebhook + ): Promise { + Logger.info(`Incoming webhook ${body.type}`, loggerCtx); + // Validate if metadata present + const orderCode = + body.data.object.metadata?.orderCode ?? + (body.data.object as StripeInvoice).lines?.data[0]?.metadata.orderCode; + 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' && + body.type !== 'invoice.payment_action_required' + ) { + 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`, + loggerCtx + ); + } + try { + const ctx = await this.stripeSubscriptionService.createContext( + channelToken, + request + ); + const order = await this.orderService.findOneByCode(ctx, orderCode); + if (!order) { + throw Error(`Cannot find order with code ${orderCode}`); // Throw inside catch block, so Stripe will retry + } + // Validate signature + const { stripeClient } = + await this.stripeSubscriptionService.getStripeContext(ctx); + if (!this.options?.disableWebhookSignatureChecking) { + stripeClient.validateWebhookSignature(request.rawBody, signature); + } + if (body.type === 'payment_intent.succeeded') { + await this.stripeSubscriptionService.handleIntentSucceeded( + ctx, + body.data.object as StripePaymentIntent, + order + ); + } else if (body.type === 'invoice.payment_failed') { + const invoiceObject = body.data.object as StripeInvoice; + await this.stripeSubscriptionService.handleInvoicePaymentFailed( + ctx, + invoiceObject, + order + ); + } else if (body.type === 'invoice.payment_action_required') { + const invoiceObject = body.data.object as StripeInvoice; + await this.stripeSubscriptionService.handleInvoicePaymentFailed( + ctx, + invoiceObject, + order + ); + } + Logger.info(`Successfully handled webhook ${body.type}`, loggerCtx); + } catch (error) { + // Catch all for logging purposes + Logger.error( + `Failed to process incoming webhook ${body.type} (${body.id}): ${ + (error as Error)?.message + }`, + loggerCtx, + (error as Error)?.stack + ); + throw error; + } + } +} diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.resolver.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.resolver.ts index bd7d5fec..865d94ae 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.resolver.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.resolver.ts @@ -33,8 +33,6 @@ export type RequestWithRawBody = Request & { rawBody: any }; export class ShopResolver { constructor( private stripeSubscriptionService: StripeSubscriptionService, - private orderService: OrderService, - private productService: ProductService, private paymentMethodService: PaymentMethodService ) {} diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts index 3b41115d..e21e822e 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts @@ -4,7 +4,6 @@ import { StockMovementType } from '@vendure/common/lib/generated-types'; import { ActiveOrderService, ChannelService, - CustomerService, EntityHydrator, ErrorResult, EventBus, @@ -23,7 +22,6 @@ import { PaymentMethod, PaymentMethodService, ProductService, - ProductVariant, ProductVariantService, RequestContext, SerializedRequestContext, @@ -38,23 +36,18 @@ import { Request } from 'express'; import { filter } from 'rxjs/operators'; import Stripe from 'stripe'; import { loggerCtx, PLUGIN_INIT_OPTIONS } from '../constants'; -import { - StripeSubscriptionBothPaymentTypes, - StripeSubscriptionIntent, - StripeSubscriptionIntentType, -} from './generated/graphql'; +import { StripeSubscriptionPluginOptions } from '../stripe-subscription.plugin'; +import { StripeSubscriptionIntent } from './generated/graphql'; import { Subscription, SubscriptionStrategy, } from './strategy/subscription-strategy'; import { StripeClient } from './stripe.client'; -import './vendure-config/custom-fields-types.d.ts'; import { StripeInvoice } from './types/stripe-invoice'; +import { StripePaymentIntent } from './types/stripe-payment-intent'; import { printMoney } from './util'; -import { StripeSubscriptionPluginOptions } from '../stripe-subscription.plugin'; -import * as types from './vendure-config/custom-fields-types'; +import './vendure-config/custom-fields-types.d.ts'; import { stripeSubscriptionHandler } from './vendure-config/stripe-subscription.handler'; -import { StripePaymentIntent } from './types/stripe-payment-intent'; export interface StripeContext { paymentMethod: PaymentMethod; @@ -345,14 +338,11 @@ export class StripeSubscriptionService { ); const stripePaymentMethods = ['card']; // TODO make configurable per channel const subscriptions = await this.defineSubscriptions(ctx, order); - const hasRecurringPayments = subscriptions.some( - (s) => (s as StripeSubscriptionBothPaymentTypes).recurring.amount > 0 - ); const hasOneTimePayments = subscriptions.some( - (s) => (s as StripeSubscriptionBothPaymentTypes).amountDueNow > 0 + (s) => (s.amountDueNow ?? 0) > 0 ); let intent: Stripe.PaymentIntent | Stripe.SetupIntent; - if (hasRecurringPayments && hasOneTimePayments) { + if (hasOneTimePayments) { // Create PaymentIntent + off_session, because we have both one-time and recurring payments intent = await stripeClient.paymentIntents.create({ customer: stripeCustomer.id, @@ -366,20 +356,6 @@ export class StripeSubscriptionService { amount: order.totalWithTax, }, }); - } else if (hasOneTimePayments) { - // Create PaymentIntent, because we only have one-time payments - intent = await stripeClient.paymentIntents.create({ - customer: stripeCustomer.id, - payment_method_types: stripePaymentMethods, - setup_future_usage: 'on_session', - amount: order.totalWithTax, - 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({ @@ -412,26 +388,40 @@ export class StripeSubscriptionService { /** * 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 defineSubscriptions( ctx: RequestContext, order: Order ): Promise { 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 subscriptions = await Promise.all( - order.lines.map((line) => - this.strategy.defineSubscription( + subscriptionOrderLines.map(async (line) => { + const subscription = await this.strategy.defineSubscription( ctx, injector, line.productVariant, line.customFields, line.quantity - ) - ) + ); + if (subscription.recurring.amount <= 0) { + throw Error( + `[${loggerCtx}]: Defined subscription for order line ${line.id} must have a recurring amount greater than 0` + ); + } + return subscription; + }) ); return subscriptions; } + /** + * Check if the order has products that should be treated as subscription products + */ hasSubscriptionProducts(ctx: RequestContext, order: Order): boolean { return order.lines.some((l) => this.strategy.isSubscription(ctx, l.productVariant) @@ -446,6 +436,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` @@ -461,10 +452,10 @@ 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, order: Order @@ -491,7 +482,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( @@ -499,7 +490,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, @@ -525,13 +516,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 ); } @@ -551,10 +546,161 @@ export class StripeSubscriptionService { 'lines.productVariant', ]); if (!order) { - throw Error(`Cannot find order with code ${orderCode}`); + throw Error(`[${loggerCtx}]: Cannot find order with code ${orderCode}`); } try { - // FIXME do stuff here + 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); + for (const subscriptionDefinition of subscriptionDefinitions) { + try { + const product = await stripeClient.products.create({ + name: subscriptionDefinition.name, + }); + const createdSubscription = + await stripeClient.createOffSessionSubscription({ + customerId: stripeCustomerId, + productId: product.id, + currencyCode: order.currencyCode, + amount: subscriptionDefinition.recurring.amount, + interval: subscriptionDefinition.recurring.interval, + intervalCount: subscriptionDefinition.recurring.intervalCount, + paymentMethodId: stripePaymentMethodId, + startDate: subscriptionDefinition.recurring.startDate, + endDate: subscriptionDefinition.recurring.endDate, + description: `'${subscriptionDefinition.name} for order '${order.code}'`, + orderCode: order.code, + channelToken: ctx.channel.token, + }); + if ( + createdSubscription.status !== 'active' && + createdSubscription.status !== 'trialing' + ) { + Logger.error( + `Failed to create active subscription ${createdSubscription.id} for order ${order.code}! It is still in status '${createdSubscription.status}'`, + loggerCtx + ); + await this.logHistoryEntry( + ctx, + order.id, + 'Failed to create subscription', + `Subscription status is ${createdSubscription.status}`, + subscriptionDefinition, + createdSubscription.id + ); + } else { + 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 + ); + } + if (pricing.downpayment) { + // FIXME add downpayment to the strategy + // 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 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 = + await stripeClient.createOffSessionSubscription({ + customerId: stripeCustomerId, + productId: downpaymentProduct.id, + currencyCode: order.currencyCode, + amount: pricing.downpayment, + interval: downpaymentInterval, + intervalCount: downpaymentIntervalCount, + paymentMethodId: stripePaymentMethodId, + startDate: nextDownpaymentDate, + endDate: pricing.subscriptionEndDate || undefined, + description: `Downpayment`, + orderCode: order.code, + channelToken: ctx.channel.token, + }); + createdSubscriptions.push(recurringSubscription.id); + if ( + downpaymentSubscription.status !== 'active' && + downpaymentSubscription.status !== 'trialing' + ) { + 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}`, + loggerCtx + ); + await this.logHistoryEntry( + ctx, + order.id, + `Created downpayment subscription for line ${orderLineCount}`, + undefined, + pricing, + downpaymentSubscription.id + ); + } + } + } catch (e: unknown) { + await this.logHistoryEntry(ctx, order.id, '', e); + throw e; + } + } + // FIXME // await this.saveSubscriptionIds(ctx, orderLine.id, createdSubscriptions); } catch (e: unknown) { await this.logHistoryEntry(ctx, order.id, '', e); diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/subscription-order-item-calculation.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/subscription-order-item-calculation.ts index a5c397d1..3977268a 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/subscription-order-item-calculation.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/subscription-order-item-calculation.ts @@ -8,8 +8,6 @@ import { } from '@vendure/core'; import { DefaultOrderItemPriceCalculationStrategy } from '@vendure/core/dist/config/order/default-order-item-price-calculation-strategy'; import { CustomOrderLineFields } from '@vendure/core/dist/entity/custom-entity-fields'; -import { StripeSubscriptionPayment } from '../api/stripe-subscription-payment.entity'; -import { OneTimePayment } from './strategy/subscription-strategy'; import { StripeSubscriptionService } from './stripe-subscription.service'; let subcriptionService: StripeSubscriptionService | undefined; @@ -44,7 +42,7 @@ export class SubscriptionOrderItemCalculation ); return { priceIncludesTax: subscription.priceIncludesTax, - price: (subscription as OneTimePayment).amountDueNow ?? 0, + price: subscription.amountDueNow ?? 0, }; } else { return super.calculateUnitPrice(ctx, productVariant); From fe3825a4454988a18e3e11983959d8bb1b57cc19 Mon Sep 17 00:00:00 2001 From: Martijn Date: Thu, 26 Oct 2023 16:01:02 +0200 Subject: [PATCH 08/23] feat(stripe-subscription): multiple subs per orderline --- .../src/api-v2/graphql-schema.ts | 3 +- .../strategy/default-subscription-strategy.ts | 3 + .../api-v2/strategy/subscription-strategy.ts | 16 +- .../api-v2/stripe-subscription.resolver.ts | 8 +- .../src/api-v2/stripe-subscription.service.ts | 178 +++++++----------- .../subscription-order-item-calculation.ts | 33 +++- .../tsconfig.json | 2 +- 7 files changed, 120 insertions(+), 123 deletions(-) diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/graphql-schema.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/graphql-schema.ts index bdd2d256..5d6d30d5 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/graphql-schema.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/graphql-schema.ts @@ -17,6 +17,7 @@ export const shopSchemaExtensions = gql` type StripeSubscription { name: String! + variantId: ID! amountDueNow: Int priceIncludesTax: Boolean! recurring: StripeSubscriptionRecurringPayment! @@ -48,7 +49,7 @@ export const shopSchemaExtensions = gql` previewStripeSubscription( productVariantId: ID! customInputs: JSON - ): StripeSubscription! + ): [StripeSubscription!]! previewStripeSubscriptionForProduct( productId: ID! customInputs: JSON diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/default-subscription-strategy.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/default-subscription-strategy.ts index a15beb0b..79c1a6eb 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/default-subscription-strategy.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/default-subscription-strategy.ts @@ -3,6 +3,7 @@ import { OrderLine, Injector, ProductVariant, + Order, } from '@vendure/core'; import { Subscription, SubscriptionStrategy } from './subscription-strategy'; @@ -16,6 +17,7 @@ export class DefaultSubscriptionStrategy implements SubscriptionStrategy { ctx: RequestContext, injector: Injector, productVariant: ProductVariant, + order: Order, orderLineCustomFields: { [key: string]: any }, quantity: number ): Subscription { @@ -41,6 +43,7 @@ export class DefaultSubscriptionStrategy implements SubscriptionStrategy { const price = productVariant.listPrice; return { name: `Subscription ${productVariant.name}`, + variantId: productVariant.id, priceIncludesTax: productVariant.listPriceIncludesTax, amountDueNow: price, recurring: { diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/subscription-strategy.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/subscription-strategy.ts index 0106aa96..f34d0b6b 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/subscription-strategy.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/subscription-strategy.ts @@ -1,5 +1,7 @@ import { + ID, Injector, + Order, OrderLine, ProductVariant, RequestContext, @@ -13,6 +15,7 @@ export interface Subscription { * Name for displaying purposes */ name: string; + variantId: ID; amountDueNow?: number; priceIncludesTax: boolean; recurring: { @@ -39,9 +42,14 @@ export interface SubscriptionStrategy { ctx: RequestContext, injector: Injector, productVariant: ProductVariant, + order: Order, orderLineCustomFields: { [key: string]: any }, quantity: number - ): Promise | Subscription; + ): + | Promise + | Subscription + | Promise + | Subscription[]; /** * Preview subscription pricing for a given product variant, because there is no order line available during preview. @@ -54,5 +62,9 @@ export interface SubscriptionStrategy { injector: Injector, productVariant: ProductVariant, customInputs?: any - ): Promise | Subscription; + ): + | Promise + | Subscription + | Promise + | Subscription[]; } diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.resolver.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.resolver.ts index 865d94ae..793eb86d 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.resolver.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.resolver.ts @@ -24,6 +24,8 @@ import { QueryPreviewStripeSubscriptionForProductArgs, StripeSubscription, StripeSubscriptionIntent, + Query as GraphqlQuery, + Mutation as GraphqlMutation, } from './generated/graphql'; import { StripeSubscriptionService } from './stripe-subscription.service'; @@ -40,7 +42,7 @@ export class ShopResolver { @Allow(Permission.Owner) async createStripeSubscriptionIntent( @Ctx() ctx: RequestContext - ): Promise { + ): Promise { return this.stripeSubscriptionService.createIntent(ctx); } @@ -49,7 +51,7 @@ export class ShopResolver { @Ctx() ctx: RequestContext, @Args() { productVariantId, customInputs }: QueryPreviewStripeSubscriptionArgs - ): Promise { + ): Promise { return this.stripeSubscriptionService.previewSubscription( ctx, productVariantId, @@ -62,7 +64,7 @@ export class ShopResolver { @Ctx() ctx: RequestContext, @Args() { productId, customInputs }: QueryPreviewStripeSubscriptionForProductArgs - ): Promise { + ): Promise { return this.stripeSubscriptionService.previewSubscriptionForProduct( ctx, productId, diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts index e21e822e..cc35850d 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts @@ -182,7 +182,7 @@ export class StripeSubscriptionService { ctx: RequestContext, productVariantId: ID, customInputs?: any - ): Promise { + ): Promise { const variant = await this.productVariantService.findOne( ctx, productVariantId @@ -193,12 +193,17 @@ export class StripeSubscriptionService { ); } const injector = new Injector(this.moduleRef); - return this.strategy.previewSubscription( + const subscriptions = await this.strategy.previewSubscription( ctx, injector, variant, customInputs ); + if (Array.isArray(subscriptions)) { + return subscriptions; + } else { + return [subscriptions]; + } } async previewSubscriptionForProduct( @@ -213,11 +218,13 @@ export class StripeSubscriptionService { throw new UserInputError(`No product with id '${product}' found`); } const injector = new Injector(this.moduleRef); - return Promise.all( + const subscriptions = await Promise.all( product.variants.map((variant) => this.strategy.previewSubscription(ctx, injector, variant, customInputs) ) ); + // Flatten, because there can be multiple subscriptions per variant, resulting in [[sub, sub], sub, sub] + return subscriptions.flat(); } async cancelSubscriptionForOrderLine( @@ -393,7 +400,7 @@ export class StripeSubscriptionService { async defineSubscriptions( ctx: RequestContext, order: Order - ): Promise { + ): Promise<(Subscription & { orderLineId: ID })[]> { const injector = new Injector(this.moduleRef); // Only define subscriptions for orderlines with a subscription product variant const subscriptionOrderLines = order.lines.filter((l) => @@ -401,22 +408,34 @@ export class StripeSubscriptionService { ); const subscriptions = await Promise.all( subscriptionOrderLines.map(async (line) => { - const subscription = await this.strategy.defineSubscription( + const subs = await this.strategy.defineSubscription( ctx, injector, line.productVariant, + order, line.customFields, line.quantity ); - if (subscription.recurring.amount <= 0) { - throw Error( - `[${loggerCtx}]: Defined subscription for order line ${line.id} must have a recurring amount greater than 0` - ); + // Add orderlineId to subscription + if (Array.isArray(subs)) { + return subs.map((sub) => ({ ...sub, orderLineId: line.id })); } - return subscription; + return { + orderLineId: line.id, + ...subs, + }; }) ); - return subscriptions; + const flattenedSubscriptionsArray = subscriptions.flat(); + // Validate recurring amount + flattenedSubscriptionsArray.forEach((subscription) => { + if (subscription.recurring.amount <= 0) { + throw Error( + `[${loggerCtx}]: Defined subscription for order line ${subscription.variantId} must have a recurring amount greater than 0` + ); + } + }); + return flattenedSubscriptionsArray; } /** @@ -568,6 +587,8 @@ export class StripeSubscriptionService { order ); Logger.info(`Creating subscriptions for ${orderCode}`, loggerCtx); + // + const subscriptionsPerOrderLine = new Map(); for (const subscriptionDefinition of subscriptionDefinitions) { try { const product = await stripeClient.products.create({ @@ -592,116 +613,61 @@ export class StripeSubscriptionService { 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 ${createdSubscription.id} for order ${order.code}! It is still in status '${createdSubscription.status}'`, + `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, - 'Failed to create subscription', + `Failed to create subscription ${subscriptionDefinition.name}`, `Subscription status is ${createdSubscription.status}`, subscriptionDefinition, createdSubscription.id ); - } else { - 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 - ); - } - if (pricing.downpayment) { - // FIXME add downpayment to the strategy - // 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 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 = - await stripeClient.createOffSessionSubscription({ - customerId: stripeCustomerId, - productId: downpaymentProduct.id, - currencyCode: order.currencyCode, - amount: pricing.downpayment, - interval: downpaymentInterval, - intervalCount: downpaymentIntervalCount, - paymentMethodId: stripePaymentMethodId, - startDate: nextDownpaymentDate, - endDate: pricing.subscriptionEndDate || undefined, - description: `Downpayment`, - orderCode: order.code, - channelToken: ctx.channel.token, - }); - createdSubscriptions.push(recurringSubscription.id); - if ( - downpaymentSubscription.status !== 'active' && - downpaymentSubscription.status !== 'trialing' - ) { - 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}`, - loggerCtx - ); - await this.logHistoryEntry( - ctx, - order.id, - `Created downpayment subscription for line ${orderLineCount}`, - undefined, - pricing, - downpaymentSubscription.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, '', e); + await this.logHistoryEntry( + ctx, + order.id, + 'An unknown error occured creating subscriptions', + e + ); throw e; } } - // FIXME - // await this.saveSubscriptionIds(ctx, orderLine.id, createdSubscriptions); + // 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; diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/subscription-order-item-calculation.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/subscription-order-item-calculation.ts index 3977268a..909668bf 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/subscription-order-item-calculation.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/subscription-order-item-calculation.ts @@ -5,6 +5,7 @@ import { PriceCalculationResult, ProductVariant, RequestContext, + UserInputError, } from '@vendure/core'; import { DefaultOrderItemPriceCalculationStrategy } from '@vendure/core/dist/config/order/default-order-item-price-calculation-strategy'; import { CustomOrderLineFields } from '@vendure/core/dist/entity/custom-entity-fields'; @@ -32,20 +33,32 @@ export class SubscriptionOrderItemCalculation if (!subcriptionService) { throw new Error('Subscription service not initialized'); } - if (subcriptionService.strategy.isSubscription(ctx, productVariant)) { - const subscription = await subcriptionService.strategy.defineSubscription( - ctx, - injector, - productVariant, - orderLineCustomFields, - orderLineQuantity - ); + 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 { 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/tsconfig.json b/packages/vendure-plugin-stripe-subscription/tsconfig.json index 306af4f7..c3bdf656 100644 --- a/packages/vendure-plugin-stripe-subscription/tsconfig.json +++ b/packages/vendure-plugin-stripe-subscription/tsconfig.json @@ -5,5 +5,5 @@ "types": ["node"] }, "include": ["src/"], - "exclude": ["src/ui"] + "exclude": ["src/ui", "src/api"] } From 19e96cd5fe86ccbddfb2f8823f5a0d4a00902a38 Mon Sep 17 00:00:00 2001 From: Martijn Date: Mon, 30 Oct 2023 11:56:39 +0100 Subject: [PATCH 09/23] fix(stripe-subscription): ready for real test with stripe webhook --- .../api-v2/stripe-subscription.resolver.ts | 5 +- .../src/api-v2/stripe-subscription.service.ts | 15 +- .../subscription-order-item-calculation.ts | 6 +- .../src/index.ts | 2 +- .../src/stripe-subscription.plugin.ts | 5 +- .../custom-fields-types.d.ts => types.ts} | 0 .../test/dev-server.ts | 76 ++++----- .../test/helpers.ts | 150 +++++++++--------- 8 files changed, 134 insertions(+), 125 deletions(-) rename packages/vendure-plugin-stripe-subscription/src/{api-v2/vendure-config/custom-fields-types.d.ts => types.ts} (100%) diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.resolver.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.resolver.ts index 793eb86d..5e16ca5b 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.resolver.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.resolver.ts @@ -32,7 +32,7 @@ import { StripeSubscriptionService } from './stripe-subscription.service'; export type RequestWithRawBody = Request & { rawBody: any }; @Resolver() -export class ShopResolver { +export class StripeSubscriptionShopResolver { constructor( private stripeSubscriptionService: StripeSubscriptionService, private paymentMethodService: PaymentMethodService @@ -43,7 +43,8 @@ export class ShopResolver { async createStripeSubscriptionIntent( @Ctx() ctx: RequestContext ): Promise { - return this.stripeSubscriptionService.createIntent(ctx); + const res = await this.stripeSubscriptionService.createIntent(ctx); + return res; } @Query() diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts index cc35850d..193c7a6c 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts @@ -46,7 +46,6 @@ import { StripeClient } from './stripe.client'; import { StripeInvoice } from './types/stripe-invoice'; import { StripePaymentIntent } from './types/stripe-payment-intent'; import { printMoney } from './util'; -import './vendure-config/custom-fields-types.d.ts'; import { stripeSubscriptionHandler } from './vendure-config/stripe-subscription.handler'; export interface StripeContext { @@ -164,7 +163,9 @@ export class StripeSubscriptionService { )[]; const orderLinesWithSubscriptions = cancelOrReleaseEvents // Filter out non-sub orderlines - .filter((event) => event.orderLine.customFields.subscriptionIds); + .filter( + (event) => (event.orderLine.customFields as any).subscriptionIds + ); await Promise.all( // Push jobs orderLinesWithSubscriptions.map((line) => @@ -239,7 +240,9 @@ export class StripeSubscriptionService { 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... `, @@ -314,6 +317,7 @@ export class StripeSubscriptionService { } await this.entityHydrator.hydrate(ctx, order, { relations: ['customer', 'shippingLines', 'lines.productVariant'], + applyProductVariantPrices: true, }); if (!order.lines?.length) { throw new UserInputError('Cannot create intent for empty order'); @@ -429,7 +433,10 @@ export class StripeSubscriptionService { const flattenedSubscriptionsArray = subscriptions.flat(); // Validate recurring amount flattenedSubscriptionsArray.forEach((subscription) => { - if (subscription.recurring.amount <= 0) { + 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` ); diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/subscription-order-item-calculation.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/subscription-order-item-calculation.ts index 909668bf..70ca42f6 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/subscription-order-item-calculation.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/subscription-order-item-calculation.ts @@ -11,15 +11,14 @@ import { DefaultOrderItemPriceCalculationStrategy } from '@vendure/core/dist/con 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 @@ -30,6 +29,7 @@ export class SubscriptionOrderItemCalculation order: Order, orderLineQuantity: number ): Promise { + const subcriptionService = injector.get(StripeSubscriptionService); if (!subcriptionService) { throw new Error('Subscription service not initialized'); } diff --git a/packages/vendure-plugin-stripe-subscription/src/index.ts b/packages/vendure-plugin-stripe-subscription/src/index.ts index ce353722..fcce9408 100644 --- a/packages/vendure-plugin-stripe-subscription/src/index.ts +++ b/packages/vendure-plugin-stripe-subscription/src/index.ts @@ -4,5 +4,5 @@ export * from './api-v2/strategy/subscription-strategy'; export * from './api-v2/strategy/default-subscription-strategy'; export * from './api-v2/vendure-config/has-stripe-subscription-products-payment-checker'; export * from './api-v2/vendure-config/stripe-subscription.handler'; -export * from './api-v2/vendure-config/custom-fields-types'; +export * from './types'; export * from './api-v2/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 13d8ddf0..87f1f2c3 100644 --- a/packages/vendure-plugin-stripe-subscription/src/stripe-subscription.plugin.ts +++ b/packages/vendure-plugin-stripe-subscription/src/stripe-subscription.plugin.ts @@ -10,6 +10,8 @@ import { orderLineCustomFields } from './api-v2/vendure-config/custom-fields'; import { stripeSubscriptionHandler } from './api-v2/vendure-config/stripe-subscription.handler'; import { hasStripeSubscriptionProductsPaymentChecker } from './api-v2/vendure-config/has-stripe-subscription-products-payment-checker'; import { SubscriptionOrderItemCalculation } from './api-v2/subscription-order-item-calculation'; +import { StripeSubscriptionService } from './api-v2/stripe-subscription.service'; +import { StripeSubscriptionShopResolver } from './api-v2/stripe-subscription.resolver'; export interface StripeSubscriptionPluginOptions { /** @@ -23,7 +25,7 @@ export interface StripeSubscriptionPluginOptions { imports: [PluginCommonModule], shopApiExtensions: { schema: shopSchemaExtensions, - resolvers: [], + resolvers: [StripeSubscriptionShopResolver], }, controllers: [], providers: [ @@ -31,6 +33,7 @@ export interface StripeSubscriptionPluginOptions { provide: PLUGIN_INIT_OPTIONS, useFactory: () => StripeSubscriptionPlugin.options, }, + StripeSubscriptionService, ], configuration: (config) => { config.paymentOptions.paymentMethodHandlers.push(stripeSubscriptionHandler); diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/custom-fields-types.d.ts b/packages/vendure-plugin-stripe-subscription/src/types.ts similarity index 100% rename from packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/custom-fields-types.d.ts rename to packages/vendure-plugin-stripe-subscription/src/types.ts diff --git a/packages/vendure-plugin-stripe-subscription/test/dev-server.ts b/packages/vendure-plugin-stripe-subscription/test/dev-server.ts index 74208ce9..9fb7264c 100644 --- a/packages/vendure-plugin-stripe-subscription/test/dev-server.ts +++ b/packages/vendure-plugin-stripe-subscription/test/dev-server.ts @@ -2,6 +2,7 @@ import { AdminUiPlugin } from '@vendure/admin-ui-plugin'; import { DefaultLogger, DefaultSearchPlugin, + LanguageCode, LogLevel, mergeConfig, } from '@vendure/core'; @@ -14,6 +15,7 @@ import { StripeSubscriptionPlugin } from '../src/stripe-subscription.plugin'; import { ADD_ITEM_TO_ORDER, CREATE_PAYMENT_LINK, + CREATE_PAYMENT_METHOD, setShipping, UPDATE_CHANNEL, UPDATE_VARIANT, @@ -80,45 +82,47 @@ export let clientSecret = 'test'; 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(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 shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test'); - // let { addItemToOrder: order } = await shopClient.query(ADD_ITEM_TO_ORDER, { - // productVariantId: '1', - // quantity: 1, - // }); + await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test'); + let { addItemToOrder: order } = await shopClient.query(ADD_ITEM_TO_ORDER, { + productVariantId: '1', + quantity: 1, + }); - // await setShipping(shopClient); - // console.log(`Prepared order ${order?.code}`); + 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`); + const { + createStripeSubscriptionIntent: { clientSecret: secret, intentType }, + } = await shopClient.query(CREATE_PAYMENT_LINK); + clientSecret = secret; + console.log( + `Go to http://localhost:3050/checkout/ to test your ${intentType}` + ); // Uncomment these lines to list all subscriptions created in Stripe // const ctx = await server.app.get(RequestContextService).create({apiType: 'admin'}); diff --git a/packages/vendure-plugin-stripe-subscription/test/helpers.ts b/packages/vendure-plugin-stripe-subscription/test/helpers.ts index 9469f660..71d50ae9 100644 --- a/packages/vendure-plugin-stripe-subscription/test/helpers.ts +++ b/packages/vendure-plugin-stripe-subscription/test/helpers.ts @@ -1,20 +1,11 @@ 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 @@ -63,72 +54,72 @@ export const UPDATE_CHANNEL = gql` } `; -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 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_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 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 CREATE_PAYMENT_METHOD = gql` mutation CreatePaymentMethod($input: CreatePaymentMethodInput!) { @@ -179,7 +170,10 @@ export const SET_SHIPPING_METHOD = gql` export const CREATE_PAYMENT_LINK = gql` mutation createStripeSubscriptionIntent { - createStripeSubscriptionIntent + createStripeSubscriptionIntent { + clientSecret + intentType + } } `; From fa1013b7666c87fb7bc7337ab7cf54a6fbbc0c36 Mon Sep 17 00:00:00 2001 From: Martijn Date: Tue, 31 Oct 2023 09:35:26 +0100 Subject: [PATCH 10/23] fix(stripe-subscription): tiny improvement --- .../src/api-v2/stripe-subscription.service.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts index 193c7a6c..001ddfba 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts @@ -312,9 +312,6 @@ export class StripeSubscriptionService { if (!order) { throw new UserInputError('No active order for session'); } - if (!order.totalWithTax) { - // This means a one time payment is needed - } await this.entityHydrator.hydrate(ctx, order, { relations: ['customer', 'shippingLines', 'lines.productVariant'], applyProductVariantPrices: true, @@ -349,12 +346,9 @@ export class StripeSubscriptionService { ); const stripePaymentMethods = ['card']; // TODO make configurable per channel const subscriptions = await this.defineSubscriptions(ctx, order); - const hasOneTimePayments = subscriptions.some( - (s) => (s.amountDueNow ?? 0) > 0 - ); let intent: Stripe.PaymentIntent | Stripe.SetupIntent; - if (hasOneTimePayments) { - // Create PaymentIntent + off_session, because we have both one-time and recurring payments + if (order.totalWithTax) { + // 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, From 5f02ce2fc88eda1009b1f1dcee981743015347fa Mon Sep 17 00:00:00 2001 From: Martijn Date: Tue, 31 Oct 2023 16:48:35 +0100 Subject: [PATCH 11/23] fix(stripe-subscription): added correct webhook events --- .../README.md | 2 ++ .../src/api-v2/graphql-schema.ts | 2 +- .../api-v2/strategy/subscription-strategy.ts | 2 +- .../api-v2/stripe-subscription.controller.ts | 25 +++++++++-------- .../src/api-v2/stripe-subscription.service.ts | 19 +++++++++---- .../src/api-v2/types/stripe-payment-intent.ts | 27 +++++++++++++++++++ .../src/stripe-subscription.plugin.ts | 3 ++- 7 files changed, 61 insertions(+), 19 deletions(-) diff --git a/packages/vendure-plugin-stripe-subscription/README.md b/packages/vendure-plugin-stripe-subscription/README.md index c0f925e9..289ba3a9 100644 --- a/packages/vendure-plugin-stripe-subscription/README.md +++ b/packages/vendure-plugin-stripe-subscription/README.md @@ -1,5 +1,7 @@ // TODO: Strategy explained. Failed invoice event // No support for non-recurring payments. Use the built Vendure plugin for that. Only for recurring payments +// Explain storefront flow. Order of graphql mutations +/ Webhook setup! # Vendure Stripe Subscription plugin diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/graphql-schema.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/graphql-schema.ts index 5d6d30d5..f7552c62 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/graphql-schema.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/graphql-schema.ts @@ -18,7 +18,7 @@ export const shopSchemaExtensions = gql` type StripeSubscription { name: String! variantId: ID! - amountDueNow: Int + amountDueNow: Int! priceIncludesTax: Boolean! recurring: StripeSubscriptionRecurringPayment! } diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/subscription-strategy.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/subscription-strategy.ts index f34d0b6b..0decca00 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/subscription-strategy.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/subscription-strategy.ts @@ -16,7 +16,7 @@ export interface Subscription { */ name: string; variantId: ID; - amountDueNow?: number; + amountDueNow: number; priceIncludesTax: boolean; recurring: { amount: number; diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.controller.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.controller.ts index 96b076e0..c741b5ff 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.controller.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.controller.ts @@ -5,7 +5,10 @@ import { loggerCtx, PLUGIN_INIT_OPTIONS } from '../constants'; import { StripeSubscriptionPluginOptions } from '../stripe-subscription.plugin'; import { StripeSubscriptionService } from './stripe-subscription.service'; import { StripeInvoice } from './types/stripe-invoice'; -import { StripePaymentIntent } from './types/stripe-payment-intent'; +import { + StripePaymentIntent, + StripeSetupIntent, +} from './types/stripe-payment-intent'; import { IncomingStripeWebhook } from './types/stripe.common'; export type RequestWithRawBody = Request & { rawBody: any }; @@ -35,6 +38,7 @@ export class StripeSubscriptionController { (body.data.object as StripeInvoice).lines?.data[0]?.metadata.channelToken; if ( body.type !== 'payment_intent.succeeded' && + body.type !== 'setup_intent.succeeded' && body.type !== 'invoice.payment_failed' && body.type !== 'invoice.payment_succeeded' && body.type !== 'invoice.payment_action_required' @@ -72,20 +76,19 @@ export class StripeSubscriptionController { if (!this.options?.disableWebhookSignatureChecking) { stripeClient.validateWebhookSignature(request.rawBody, signature); } - if (body.type === 'payment_intent.succeeded') { + 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_failed') { - const invoiceObject = body.data.object as StripeInvoice; - await this.stripeSubscriptionService.handleInvoicePaymentFailed( - ctx, - invoiceObject, - order - ); - } 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, diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts index 001ddfba..0abc2ca6 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts @@ -44,7 +44,10 @@ import { } from './strategy/subscription-strategy'; import { StripeClient } from './stripe.client'; import { StripeInvoice } from './types/stripe-invoice'; -import { StripePaymentIntent } from './types/stripe-payment-intent'; +import { + StripePaymentIntent, + StripeSetupIntent, +} from './types/stripe-payment-intent'; import { printMoney } from './util'; import { stripeSubscriptionHandler } from './vendure-config/stripe-subscription.handler'; @@ -179,6 +182,8 @@ export class StripeSubscriptionService { }); } + // TODO automatically register webhooks on startup + async previewSubscription( ctx: RequestContext, productVariantId: ID, @@ -338,16 +343,20 @@ export class StripeSubscriptionService { const { stripeClient, paymentMethod } = await this.getStripeContext(ctx); if (!eligibleStripeMethodCodes.includes(paymentMethod.code)) { throw new UserInputError( - `No eligible payment method found with handler code '${stripeSubscriptionHandler.code}'` + `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 ); const stripePaymentMethods = ['card']; // TODO make configurable per channel - const subscriptions = await this.defineSubscriptions(ctx, order); let intent: Stripe.PaymentIntent | Stripe.SetupIntent; - if (order.totalWithTax) { + 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, @@ -477,7 +486,7 @@ export class StripeSubscriptionService { */ async handleIntentSucceeded( ctx: RequestContext, - object: StripePaymentIntent, + object: StripePaymentIntent | StripeSetupIntent, order: Order ): Promise { const { diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/types/stripe-payment-intent.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/types/stripe-payment-intent.ts index a82d232e..a6adb72b 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/types/stripe-payment-intent.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/types/stripe-payment-intent.ts @@ -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/stripe-subscription.plugin.ts b/packages/vendure-plugin-stripe-subscription/src/stripe-subscription.plugin.ts index 87f1f2c3..22e4c911 100644 --- a/packages/vendure-plugin-stripe-subscription/src/stripe-subscription.plugin.ts +++ b/packages/vendure-plugin-stripe-subscription/src/stripe-subscription.plugin.ts @@ -12,6 +12,7 @@ import { hasStripeSubscriptionProductsPaymentChecker } from './api-v2/vendure-co import { SubscriptionOrderItemCalculation } from './api-v2/subscription-order-item-calculation'; import { StripeSubscriptionService } from './api-v2/stripe-subscription.service'; import { StripeSubscriptionShopResolver } from './api-v2/stripe-subscription.resolver'; +import { StripeSubscriptionController } from './api-v2/stripe-subscription.controller'; export interface StripeSubscriptionPluginOptions { /** @@ -27,7 +28,7 @@ export interface StripeSubscriptionPluginOptions { schema: shopSchemaExtensions, resolvers: [StripeSubscriptionShopResolver], }, - controllers: [], + controllers: [StripeSubscriptionController], providers: [ { provide: PLUGIN_INIT_OPTIONS, From b67de7affd1a7048d2a708f4ad7ca7a99d085e8c Mon Sep 17 00:00:00 2001 From: Martijn Date: Tue, 31 Oct 2023 17:04:21 +0100 Subject: [PATCH 12/23] fix(stripe-subscription): register events atuomatically --- .../README.md | 2 +- .../src/api-v2/stripe-subscription.service.ts | 27 ++++++++++++++++++- .../stripe-subscription.handler.ts | 2 +- .../src/stripe-subscription.plugin.ts | 2 ++ .../test/dev-server.ts | 4 ++- 5 files changed, 33 insertions(+), 4 deletions(-) diff --git a/packages/vendure-plugin-stripe-subscription/README.md b/packages/vendure-plugin-stripe-subscription/README.md index 289ba3a9..9ca0f258 100644 --- a/packages/vendure-plugin-stripe-subscription/README.md +++ b/packages/vendure-plugin-stripe-subscription/README.md @@ -1,7 +1,7 @@ // TODO: Strategy explained. Failed invoice event // No support for non-recurring payments. Use the built Vendure plugin for that. Only for recurring payments // Explain storefront flow. Order of graphql mutations -/ Webhook setup! +// Webhook setup! Start Vendure, set ApiKey, save FIRST. Go to Stripe, see created webhook and copy the secret # Vendure Stripe Subscription plugin diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts index 0abc2ca6..edc52bf2 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts @@ -95,6 +95,16 @@ export class StripeSubscriptionService { 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 @@ -182,7 +192,22 @@ export class StripeSubscriptionService { }); } - // TODO automatically register webhooks on startup + // TODO automatically register webhooks on config save + + /** + * Register webhooks with the right events if they don't exist yet. + * Does not handle secrets, thats a manual step! + */ + async registerWebhooks(ctx: RequestContext): Promise { + const { stripeClient } = await this.getStripeContext(ctx); + const webhookUrl = `${this.options.vendureHost}/stripe-subscriptions/webhook`; + // Get existing webhooks and check if url and events match. If not, create them + + await stripeClient.webhookEndpoints.create({ + enabled_events: StripeSubscriptionService.webhookEvents, + url: webhookUrl, + }); + } async previewSubscription( ctx: RequestContext, diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/stripe-subscription.handler.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/stripe-subscription.handler.ts index 62d401f1..a4395501 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/stripe-subscription.handler.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/stripe-subscription.handler.ts @@ -54,7 +54,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/stripe-subscription.plugin.ts b/packages/vendure-plugin-stripe-subscription/src/stripe-subscription.plugin.ts index 22e4c911..5f61a255 100644 --- a/packages/vendure-plugin-stripe-subscription/src/stripe-subscription.plugin.ts +++ b/packages/vendure-plugin-stripe-subscription/src/stripe-subscription.plugin.ts @@ -19,6 +19,7 @@ export interface StripeSubscriptionPluginOptions { * Only use this for testing purposes, NEVER in production */ disableWebhookSignatureChecking?: boolean; + vendureHost: string; subscriptionStrategy?: SubscriptionStrategy; } @@ -55,6 +56,7 @@ export interface StripeSubscriptionPluginOptions { export class StripeSubscriptionPlugin { static options: StripeSubscriptionPluginOptions = { disableWebhookSignatureChecking: false, + vendureHost: '', subscriptionStrategy: new DefaultSubscriptionStrategy(), }; diff --git a/packages/vendure-plugin-stripe-subscription/test/dev-server.ts b/packages/vendure-plugin-stripe-subscription/test/dev-server.ts index 9fb7264c..20171cd3 100644 --- a/packages/vendure-plugin-stripe-subscription/test/dev-server.ts +++ b/packages/vendure-plugin-stripe-subscription/test/dev-server.ts @@ -43,7 +43,9 @@ export let clientSecret = 'test'; }, plugins: [ StripeTestCheckoutPlugin, - StripeSubscriptionPlugin, + StripeSubscriptionPlugin.init({ + vendureHost: process.env.VENDURE_HOST!, + }), DefaultSearchPlugin, AdminUiPlugin.init({ port: 3002, From 6c6bbbe2ba87433bb29c3bda49056c11681b34e5 Mon Sep 17 00:00:00 2001 From: Martijn Date: Wed, 1 Nov 2023 08:03:23 +0100 Subject: [PATCH 13/23] fix: wip --- .../src/api-v2/stripe-subscription.controller.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.controller.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.controller.ts index c741b5ff..af2c1444 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.controller.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.controller.ts @@ -36,6 +36,7 @@ export class StripeSubscriptionController { const channelToken = body.data.object.metadata?.channelToken ?? (body.data.object as StripeInvoice).lines?.data[0]?.metadata.channelToken; + // TODO get events from Service static events if ( body.type !== 'payment_intent.succeeded' && body.type !== 'setup_intent.succeeded' && From 53b1025662bd20c76904238533bdd1963e2c318a Mon Sep 17 00:00:00 2001 From: Martijn Date: Wed, 1 Nov 2023 12:07:23 +0100 Subject: [PATCH 14/23] fix(stripe-subscription): finalized docs --- .../README.md | 522 ++++---- .../README_backup.md | 371 ------ .../codegen.yml | 4 +- .../api-v2/stripe-subscription.controller.ts | 113 -- .../src/api-v2/stripe-subscription.service.ts | 812 ------------- .../src/api-v2/stripe.client.ts | 106 -- .../subscription-order-item-calculation.ts | 64 - .../src/api-v2/types/stripe-invoice.ts | 200 --- .../src/api-v2/types/stripe-payment-intent.ts | 213 ---- .../src/{api-v2 => api}/graphql-schema.ts | 0 .../src/api/graphql-schemas.ts | 182 --- ...e-subscription-products-payment-checker.ts | 30 - .../src/api/pricing.helper.ts | 347 ------ .../src/api/schedule.entity.ts | 57 - .../src/api/schedule.service.ts | 96 -- .../strategy/default-subscription-strategy.ts | 4 +- .../strategy/subscription-strategy.ts | 0 .../api/stripe-subscription-payment.entity.ts | 34 - .../src/api/stripe-subscription.controller.ts | 260 +--- .../src/api/stripe-subscription.handler.ts | 123 -- .../stripe-subscription.resolver.ts | 0 .../src/api/stripe-subscription.service.ts | 769 ++++++------ .../src/api/stripe.client.ts | 16 +- .../src/api/subscription-custom-fields.ts | 151 --- .../subscription-order-item-calculation.ts | 60 +- .../src/api/subscription.promotion.ts | 245 ---- .../src/api/types/stripe-invoice.ts | 2 +- .../src/api/types/stripe-payment-intent.ts | 29 +- .../{api-v2 => api}/types/stripe.common.ts | 0 .../src/api/types/stripe.types.ts | 30 - .../src/{api-v2 => api}/util.ts | 0 .../vendure-config/custom-fields.ts | 0 ...e-subscription-products-payment-checker.ts | 0 .../stripe-subscription.handler.ts | 12 +- .../src/index.ts | 15 +- .../src/stripe-subscription.plugin.ts | 21 +- .../src/ui/history-entry.component.ts | 10 +- .../ui/stripe-subscription-shared.module.ts | 2 - .../test/dev-server.ts | 48 +- .../test/dev-server_backup.ts | 237 ---- .../test/stripe-subscription.spec.ts | 24 +- .../stripe-subscription.spec.ts.backup.ts | 1074 +++++++++++++++++ .../test/stripe-test-checkout.plugin.ts | 10 +- .../tsconfig.json | 2 +- 44 files changed, 1858 insertions(+), 4437 deletions(-) delete mode 100644 packages/vendure-plugin-stripe-subscription/README_backup.md delete mode 100644 packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.controller.ts delete mode 100644 packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts delete mode 100644 packages/vendure-plugin-stripe-subscription/src/api-v2/stripe.client.ts delete mode 100644 packages/vendure-plugin-stripe-subscription/src/api-v2/subscription-order-item-calculation.ts delete mode 100644 packages/vendure-plugin-stripe-subscription/src/api-v2/types/stripe-invoice.ts delete mode 100644 packages/vendure-plugin-stripe-subscription/src/api-v2/types/stripe-payment-intent.ts rename packages/vendure-plugin-stripe-subscription/src/{api-v2 => api}/graphql-schema.ts (100%) delete mode 100644 packages/vendure-plugin-stripe-subscription/src/api/graphql-schemas.ts delete mode 100644 packages/vendure-plugin-stripe-subscription/src/api/has-stripe-subscription-products-payment-checker.ts delete mode 100644 packages/vendure-plugin-stripe-subscription/src/api/pricing.helper.ts delete mode 100644 packages/vendure-plugin-stripe-subscription/src/api/schedule.entity.ts delete mode 100644 packages/vendure-plugin-stripe-subscription/src/api/schedule.service.ts rename packages/vendure-plugin-stripe-subscription/src/{api-v2 => api}/strategy/default-subscription-strategy.ts (94%) rename packages/vendure-plugin-stripe-subscription/src/{api-v2 => api}/strategy/subscription-strategy.ts (100%) delete mode 100644 packages/vendure-plugin-stripe-subscription/src/api/stripe-subscription-payment.entity.ts delete mode 100644 packages/vendure-plugin-stripe-subscription/src/api/stripe-subscription.handler.ts rename packages/vendure-plugin-stripe-subscription/src/{api-v2 => api}/stripe-subscription.resolver.ts (100%) delete mode 100644 packages/vendure-plugin-stripe-subscription/src/api/subscription-custom-fields.ts delete mode 100644 packages/vendure-plugin-stripe-subscription/src/api/subscription.promotion.ts rename packages/vendure-plugin-stripe-subscription/src/{api-v2 => api}/types/stripe.common.ts (100%) delete mode 100644 packages/vendure-plugin-stripe-subscription/src/api/types/stripe.types.ts rename packages/vendure-plugin-stripe-subscription/src/{api-v2 => api}/util.ts (100%) rename packages/vendure-plugin-stripe-subscription/src/{api-v2 => api}/vendure-config/custom-fields.ts (100%) rename packages/vendure-plugin-stripe-subscription/src/{api-v2 => api}/vendure-config/has-stripe-subscription-products-payment-checker.ts (100%) rename packages/vendure-plugin-stripe-subscription/src/{api-v2 => api}/vendure-config/stripe-subscription.handler.ts (87%) delete mode 100644 packages/vendure-plugin-stripe-subscription/test/dev-server_backup.ts create mode 100644 packages/vendure-plugin-stripe-subscription/test/stripe-subscription.spec.ts.backup.ts diff --git a/packages/vendure-plugin-stripe-subscription/README.md b/packages/vendure-plugin-stripe-subscription/README.md index 9ca0f258..50b58f3e 100644 --- a/packages/vendure-plugin-stripe-subscription/README.md +++ b/packages/vendure-plugin-stripe-subscription/README.md @@ -1,61 +1,50 @@ -// TODO: Strategy explained. Failed invoice event -// No support for non-recurring payments. Use the built Vendure plugin for that. Only for recurring payments -// Explain storefront flow. Order of graphql mutations -// Webhook setup! Start Vendure, set ApiKey, save FIRST. Go to Stripe, see created webhook and copy the secret - # Vendure Stripe Subscription plugin ![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 +- [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 $30,- and schedule `Duration of 6 months, billed monthly` is a subscription where the customer is -billed $30,- per month for 6 months. - -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', @@ -67,39 +56,51 @@ 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 +{ + previewStripeSubscription(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 `previewStripeSubscriptionForProduct` +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. +9. The customer can now enter his credit card credentials. +10. Vendure will create the subscriptions in the background, after the intent has successfully been completed by the customer. +11. 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: @@ -113,253 +114,219 @@ 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. +## Custom subscription strategy -## Refunding subscriptions +You can define your own subscriptions by implementing the `StripeSubscriptionStrategy`: -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 - -## Paid up front - -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}`, + variantId: productVariant.id, + 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}`, + variantId: productVariant.id, + 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( +{ + previewStripeSubscriptionForProduct( 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}`, + variantId: productVariant.id, + 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}`, + variantId: productVariant.id, + 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`, + variantId: productVariant.id, + priceIncludesTax: productVariant.listPriceIncludesTax, + amountDueNow: 0, + recurring: { + amount: 9000, + interval: 'month', + intervalCount: 1, + startDate: new Date(), + }, + }, { + name: `yearly fee`, + variantId: productVariant.id, + 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 @@ -367,10 +334,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/README_backup.md b/packages/vendure-plugin-stripe-subscription/README_backup.md deleted file mode 100644 index 2d12f9e6..00000000 --- a/packages/vendure-plugin-stripe-subscription/README_backup.md +++ /dev/null @@ -1,371 +0,0 @@ -# Vendure Stripe Subscription plugin - -![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 -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. - -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. - -Currently, subscriptions auto-renew after their duration: After 12 months, the customer is again billed $300 per 2 -momnths for 12 months. - -## Getting started - -### Setup Stripe webhook - -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` - -## Vendure setup - -3. Add the plugin to your `vendure-config.ts` plugins and admin UI compilation: - -```ts -import { StripeSubscriptionPlugin } from 'vendure-plugin-stripe-subscription'; - -plugins: [ - StripeSubscriptionPlugin, - AdminUiPlugin.init({ - port: 3002, - route: 'admin', - app: compileUiExtensions({ - outputPath: path.join(__dirname, '__admin-ui'), - extensions: [StripeSubscriptionPlugin.ui], - }), - }), -]; -``` - -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. - -## 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. - -It's important to inform your customers what you will be billing them in the -future: https://stripe.com/docs/payments/setup-intents#mandates - -![](docs/subscription-events.png) -_After order settlement you can view the subscription details on the order history_ - -![](docs/sequence.png) -_Subscriptions are created in the background, after a customer has finished the checkout_ - -#### 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: - -```graphql -{ - eligiblePaymentMethods { - id - name - stripeSubscriptionPublishableKey - } -} -``` - -## 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 - -## Paid up front - -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. - -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. - -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. - -## Prorations - -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. - -Proration can be configured on a schedule. With `useProration=false` a customer isn't charged for the remaining days during checkout. - -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. - -## Storefront defined start dates - -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: - -```graphql -mutation { - addItemToOrder( - productVariantId: 1 - quantity: 1 - customFields: { startDate: "2023-01-31T09:18:28.533Z" } - ) { - ... on Order { - id - } - } -} -``` - -## Storefront defined down payments - -A customer can also choose to pay a higher down payment, to lower the recurring costs of a subscription. - -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 - } - } -} -``` - -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 - -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 - } - } -} -``` - -`Downpayment` and `startDate` are optional parameters. Without them, the defaults defined by the schedule will be used. - -### Get subscription pricing details per order line - -You can also get the subscription and Schedule pricing details per order line with the following query: - -```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 - } - } - } -``` - -### Discount subscription payments - -Example of a discount on subscription payments: - -- 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. - -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. - -`StripeSubscriptionPricing.originalrecurringPrice` will have the non-discounted subscription price, while `StripeSubscriptionPricing.recurringPrice` will have the final discounted price. - -### Custom future payments promotions - -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)! - -The `SubscriptionPromotionAction` will discount all subscriptions in an order. - -```ts -// Example fixed discount promotion -import { SubscriptionPromotionAction } from 'vendure-plugin-stripe-subscription'; - -/** - * 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. - -### Contributing and dev server - -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_ -``` - -2. Run `yarn start` -3. Go to `http://localhost:3050/checkout` to view the Stripe checkout diff --git a/packages/vendure-plugin-stripe-subscription/codegen.yml b/packages/vendure-plugin-stripe-subscription/codegen.yml index dd12d6a1..79b74c1b 100644 --- a/packages/vendure-plugin-stripe-subscription/codegen.yml +++ b/packages/vendure-plugin-stripe-subscription/codegen.yml @@ -1,6 +1,6 @@ -schema: 'src/api-v2/graphql-schema.ts' +schema: 'src/api/graphql-schema.ts' generates: - ./src/api-v2/generated/graphql.ts: + ./src/api/generated/graphql.ts: plugins: - typescript - typescript-operations diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.controller.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.controller.ts deleted file mode 100644 index af2c1444..00000000 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.controller.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { Body, Controller, Headers, Inject, Post, Req } from '@nestjs/common'; -import { Logger, OrderService } from '@vendure/core'; -import { Request } from 'express'; -import { loggerCtx, PLUGIN_INIT_OPTIONS } from '../constants'; -import { StripeSubscriptionPluginOptions } from '../stripe-subscription.plugin'; -import { StripeSubscriptionService } from './stripe-subscription.service'; -import { StripeInvoice } from './types/stripe-invoice'; -import { - StripePaymentIntent, - StripeSetupIntent, -} from './types/stripe-payment-intent'; -import { IncomingStripeWebhook } from './types/stripe.common'; - -export type RequestWithRawBody = Request & { rawBody: any }; - -@Controller('stripe-subscriptions') -export class StripeSubscriptionController { - constructor( - private stripeSubscriptionService: StripeSubscriptionService, - private orderService: OrderService, - @Inject(PLUGIN_INIT_OPTIONS) - private options: StripeSubscriptionPluginOptions - ) {} - - @Post('webhook') - async webhook( - @Headers('stripe-signature') signature: string | undefined, - @Req() request: RequestWithRawBody, - @Body() body: IncomingStripeWebhook - ): Promise { - Logger.info(`Incoming webhook ${body.type}`, loggerCtx); - // Validate if metadata present - const orderCode = - body.data.object.metadata?.orderCode ?? - (body.data.object as StripeInvoice).lines?.data[0]?.metadata.orderCode; - const channelToken = - body.data.object.metadata?.channelToken ?? - (body.data.object as StripeInvoice).lines?.data[0]?.metadata.channelToken; - // TODO get events from Service static events - if ( - body.type !== 'payment_intent.succeeded' && - body.type !== 'setup_intent.succeeded' && - body.type !== 'invoice.payment_failed' && - body.type !== 'invoice.payment_succeeded' && - body.type !== 'invoice.payment_action_required' - ) { - 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`, - loggerCtx - ); - } - try { - const ctx = await this.stripeSubscriptionService.createContext( - channelToken, - request - ); - const order = await this.orderService.findOneByCode(ctx, orderCode); - if (!order) { - throw Error(`Cannot find order with code ${orderCode}`); // Throw inside catch block, so Stripe will retry - } - // Validate signature - const { stripeClient } = - await this.stripeSubscriptionService.getStripeContext(ctx); - if (!this.options?.disableWebhookSignatureChecking) { - stripeClient.validateWebhookSignature(request.rawBody, signature); - } - if ( - body.type === 'payment_intent.succeeded' || - body.type === 'setup_intent.succeeded' - ) { - await this.stripeSubscriptionService.handleIntentSucceeded( - ctx, - body.data.object as StripePaymentIntent & StripeSetupIntent, - order - ); - } 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 - ); - } - Logger.info(`Successfully handled webhook ${body.type}`, loggerCtx); - } catch (error) { - // Catch all for logging purposes - Logger.error( - `Failed to process incoming webhook ${body.type} (${body.id}): ${ - (error as Error)?.message - }`, - loggerCtx, - (error as Error)?.stack - ); - throw error; - } - } -} diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts deleted file mode 100644 index edc52bf2..00000000 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.service.ts +++ /dev/null @@ -1,812 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { ModuleRef } from '@nestjs/core'; -import { StockMovementType } from '@vendure/common/lib/generated-types'; -import { - ActiveOrderService, - ChannelService, - EntityHydrator, - ErrorResult, - EventBus, - HistoryService, - ID, - Injector, - JobQueue, - JobQueueService, - LanguageCode, - Logger, - Order, - OrderLine, - OrderLineEvent, - OrderService, - OrderStateTransitionError, - PaymentMethod, - PaymentMethodService, - ProductService, - ProductVariantService, - RequestContext, - SerializedRequestContext, - StockMovementEvent, - TransactionalConnection, - UserInputError, -} from '@vendure/core'; -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 { 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 { 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; - stripeClient: StripeClient; -} - -interface CreateSubscriptionsJob { - action: 'createSubscriptionsForOrder'; - ctx: SerializedRequestContext; - orderCode: string; - stripeCustomerId: string; - stripePaymentMethodId: string; -} - -interface CancelSubscriptionsJob { - action: 'cancelSubscriptionsForOrderline'; - ctx: SerializedRequestContext; - orderLineId: ID; -} - -export type JobData = CreateSubscriptionsJob | CancelSubscriptionsJob; - -@Injectable() -export class StripeSubscriptionService { - constructor( - private paymentMethodService: PaymentMethodService, - private activeOrderService: ActiveOrderService, - private entityHydrator: EntityHydrator, - private channelService: ChannelService, - private orderService: OrderService, - private historyService: HistoryService, - private eventBus: EventBus, - private jobQueueService: JobQueueService, - 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 - this.jobQueue = await this.jobQueueService.createQueue({ - name: 'stripe-subscription', - process: async ({ data, id }) => { - const ctx = RequestContext.deserialize(data.ctx); - if (data.action === 'cancelSubscriptionsForOrderline') { - this.cancelSubscriptionForOrderLine(ctx, data.orderLineId); - } else if (data.action === 'createSubscriptionsForOrder') { - const order = await this.orderService.findOneByCode( - ctx, - data.orderCode, - [] - ); - try { - await this.createSubscriptions( - ctx, - data.orderCode, - data.stripeCustomerId, - data.stripePaymentMethodId - ); - } catch (error) { - Logger.warn( - `Failed to process job ${data.action} (${id}) for channel ${data.ctx._channel.token}: ${error}`, - loggerCtx - ); - if (order) { - await this.logHistoryEntry( - ctx, - order.id, - 'Failed to create subscription', - error - ); - } - throw error; - } - } - }, - }); - // Add unique hash for subscriptions, so Vendure creates a new order line - this.eventBus.ofType(OrderLineEvent).subscribe(async (event) => { - if ( - event.type === 'created' && - this.strategy.isSubscription(event.ctx, event.orderLine.productVariant) - ) { - await this.connection - .getRepository(event.ctx, OrderLine) - .update( - { id: event.orderLine.id }, - { customFields: { subscriptionHash: randomUUID() } } - ); - } - }); - // Listen for stock cancellation or release events, to cancel an order lines subscription - this.eventBus - .ofType(StockMovementEvent) - .pipe( - filter( - (event) => - event.type === StockMovementType.RELEASE || - event.type === StockMovementType.CANCELLATION - ) - ) - .subscribe(async (event) => { - const cancelOrReleaseEvents = event.stockMovements as ( - | Cancellation - | Release - )[]; - const orderLinesWithSubscriptions = cancelOrReleaseEvents - // Filter out non-sub orderlines - .filter( - (event) => (event.orderLine.customFields as any).subscriptionIds - ); - await Promise.all( - // Push jobs - orderLinesWithSubscriptions.map((line) => - this.jobQueue.add({ - ctx: event.ctx.serialize(), - action: 'cancelSubscriptionsForOrderline', - orderLineId: line.id, - }) - ) - ); - }); - } - - // TODO automatically register webhooks on config save - - /** - * Register webhooks with the right events if they don't exist yet. - * Does not handle secrets, thats a manual step! - */ - async registerWebhooks(ctx: RequestContext): Promise { - const { stripeClient } = await this.getStripeContext(ctx); - const webhookUrl = `${this.options.vendureHost}/stripe-subscriptions/webhook`; - // Get existing webhooks and check if url and events match. If not, create them - - await stripeClient.webhookEndpoints.create({ - enabled_events: StripeSubscriptionService.webhookEvents, - url: webhookUrl, - }); - } - - 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; - } else { - return [subscriptions]; - } - } - - async previewSubscriptionForProduct( - ctx: RequestContext, - productId: ID, - customInputs?: any - ): Promise { - const product = await this.productService.findOne(ctx, productId, [ - 'variants', - ]); - if (!product) { - throw new UserInputError(`No product with id '${product}' found`); - } - const injector = new Injector(this.moduleRef); - const subscriptions = await Promise.all( - product.variants.map((variant) => - this.strategy.previewSubscription(ctx, injector, variant, customInputs) - ) - ); - // Flatten, because there can be multiple subscriptions per variant, resulting in [[sub, sub], sub, sub] - return subscriptions.flat(); - } - - async cancelSubscriptionForOrderLine( - ctx: RequestContext, - orderLineId: ID - ): Promise { - const order = await this.orderService.findOneByOrderLineId( - ctx, - orderLineId, - ['lines'] - ); - if (!order) { - throw Error(`Order for OrderLine ${orderLineId} not found`); - } - 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... `, - loggerCtx - ); - } - await this.entityHydrator.hydrate(ctx, line, { relations: ['order'] }); - const { stripeClient } = await this.getStripeContext(ctx); - for (const subscriptionId of line.customFields.subscriptionIds) { - try { - await stripeClient.subscriptions.update(subscriptionId, { - cancel_at_period_end: true, - }); - Logger.info(`Cancelled subscription ${subscriptionId}`); - await this.logHistoryEntry( - ctx, - order!.id, - `Cancelled subscription ${subscriptionId}`, - undefined, - undefined, - subscriptionId - ); - } catch (e: unknown) { - Logger.error( - `Failed to cancel subscription ${subscriptionId}`, - loggerCtx - ); - await this.logHistoryEntry( - ctx, - order.id, - `Failed to cancel ${subscriptionId}`, - e, - undefined, - subscriptionId - ); - } - } - } - - /** - * Proxy to Stripe to retrieve subscriptions created for the current channel. - * Proxies to the Stripe api, so you can use the same filtering, parameters and options as defined here - * https://stripe.com/docs/api/subscriptions/list - */ - async getAllSubscriptions( - ctx: RequestContext, - params?: Stripe.SubscriptionListParams, - options?: Stripe.RequestOptions - ): Promise> { - const { stripeClient } = await this.getStripeContext(ctx); - return stripeClient.subscriptions.list(params, options); - } - - /** - * Get a subscription directly from Stripe - */ - async getSubscription( - ctx: RequestContext, - subscriptionId: string - ): Promise> { - const { stripeClient } = await this.getStripeContext(ctx); - return stripeClient.subscriptions.retrieve(subscriptionId); - } - - async createIntent(ctx: RequestContext): Promise { - let order = await this.activeOrderService.getActiveOrder(ctx, undefined); - if (!order) { - throw new UserInputError('No active order for session'); - } - await this.entityHydrator.hydrate(ctx, order, { - relations: ['customer', 'shippingLines', 'lines.productVariant'], - applyProductVariantPrices: true, - }); - if (!order.lines?.length) { - throw new UserInputError('Cannot create intent for empty order'); - } - if (!order.customer) { - throw new UserInputError( - 'Cannot create intent for order without customer' - ); - } - if (!order.shippingLines?.length) { - throw new UserInputError( - 'Cannot create intent for order without shippingMethod' - ); - } - // Check if Stripe Subscription paymentMethod is eligible for this order - const eligibleStripeMethodCodes = ( - await this.orderService.getEligiblePaymentMethods(ctx, order.id) - ) - .filter((m) => m.isEligible) - .map((m) => m.code); - const { stripeClient, paymentMethod } = await this.getStripeContext(ctx); - if (!eligibleStripeMethodCodes.includes(paymentMethod.code)) { - throw new UserInputError( - `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 - ); - 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, - 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, - }, - }); - } - const intentType = - intent.object === 'payment_intent' ? 'PaymentIntent' : 'SetupIntent'; - if (!intent.client_secret) { - throw Error( - `No client_secret found in ${intentType} response, something went wrong!` - ); - } - Logger.info( - `Created ${intentType} '${intent.id}' for order ${order.code}`, - loggerCtx - ); - return { - clientSecret: intent.client_secret, - intentType, - }; - } - - /** - * 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 defineSubscriptions( - ctx: RequestContext, - order: Order - ): Promise<(Subscription & { orderLineId: 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 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) => ({ ...sub, orderLineId: line.id })); - } - return { - orderLineId: line.id, - ...subs, - }; - }) - ); - 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` - ); - } - }); - return flattenedSubscriptionsArray; - } - - /** - * Check if the order has products that should be treated as subscription products - */ - hasSubscriptionProducts(ctx: RequestContext, order: Order): boolean { - return order.lines.some((l) => - this.strategy.isSubscription(ctx, l.productVariant) - ); - } - - /** - * Handle failed subscription payments that come in after the initial payment intent - */ - async handleInvoicePaymentFailed( - ctx: RequestContext, - 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` - : 'Subscription payment failed'; - await this.logHistoryEntry( - ctx, - order.id, - message, - `${message} - ${object.id}`, - undefined, - object.subscription - ); - } - - /** - * Handle the initial succeeded setup or payment intent. - * Creates subscriptions in Stripe in the background via the jobqueue - */ - async handleIntentSucceeded( - ctx: RequestContext, - object: StripePaymentIntent | StripeSetupIntent, - order: Order - ): Promise { - const { - paymentMethod: { code: paymentMethodCode }, - } = await this.getStripeContext(ctx); - if (!object.customer) { - await this.logHistoryEntry( - ctx, - order.id, - '', - `No customer ID found in incoming webhook. Can not create subscriptions for this order.` - ); - throw Error(`No customer found in webhook data for order ${order.code}`); - } - // Create subscriptions for customer - this.jobQueue - .add( - { - action: 'createSubscriptionsForOrder', - ctx: ctx.serialize(), - orderCode: order.code, - stripePaymentMethodId: object.payment_method, - stripeCustomerId: object.customer, - }, - { retries: 0 } // Only 1 try, because subscription creation isn't idempotent - ) - .catch((e) => - Logger.error( - `Failed to add subscription-creation job to queue`, - loggerCtx - ) - ); - // Settle payment for order - if (order.state !== 'ArrangingPayment') { - const transitionToStateResult = await this.orderService.transitionToState( - ctx, - order.id, - 'ArrangingPayment' - ); - if (transitionToStateResult instanceof OrderStateTransitionError) { - throw Error( - `Error transitioning order ${order.code} from ${transitionToStateResult.fromState} to ${transitionToStateResult.toState}: ${transitionToStateResult.message}` - ); - } - } - const addPaymentToOrderResult = await this.orderService.addPaymentToOrder( - ctx, - order.id, - { - method: paymentMethodCode, - metadata: { - setupIntentId: object.id, - amount: object.metadata.amount, - }, - } - ); - if ((addPaymentToOrderResult as ErrorResult).errorCode) { - throw Error( - `[${loggerCtx}]: Error adding payment to order ${order.code}: ${ - (addPaymentToOrderResult as ErrorResult).message - }` - ); - } - Logger.info( - `Successfully settled payment for order ${ - order.code - } with amount ${printMoney(object.metadata.amount)}, for channel ${ - ctx.channel.token - }`, - loggerCtx - ); - } - - /** - * Create subscriptions for customer based on order - */ - private async createSubscriptions( - ctx: RequestContext, - orderCode: string, - stripeCustomerId: string, - stripePaymentMethodId: string - ): Promise { - const order = await this.orderService.findOneByCode(ctx, orderCode, [ - 'customer', - 'lines', - 'lines.productVariant', - ]); - if (!order) { - throw Error(`[${loggerCtx}]: Cannot find order with code ${orderCode}`); - } - 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); - // - const subscriptionsPerOrderLine = new Map(); - for (const subscriptionDefinition of subscriptionDefinitions) { - try { - const product = await stripeClient.products.create({ - name: subscriptionDefinition.name, - }); - const createdSubscription = - await stripeClient.createOffSessionSubscription({ - customerId: stripeCustomerId, - productId: product.id, - currencyCode: order.currencyCode, - amount: subscriptionDefinition.recurring.amount, - interval: subscriptionDefinition.recurring.interval, - intervalCount: subscriptionDefinition.recurring.intervalCount, - paymentMethodId: stripePaymentMethodId, - startDate: subscriptionDefinition.recurring.startDate, - endDate: subscriptionDefinition.recurring.endDate, - description: `'${subscriptionDefinition.name} for order '${order.code}'`, - orderCode: order.code, - channelToken: ctx.channel.token, - }); - if ( - 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 ${subscriptionDefinition.name} (${createdSubscription.id}) for order ${order.code}! It is still in status '${createdSubscription.status}'`, - loggerCtx - ); - await this.logHistoryEntry( - ctx, - order.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; - } - } - // 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 } }); - } - - async createContext( - channelToken: string, - req: Request - ): Promise { - const channel = await this.channelService.getChannelFromToken(channelToken); - return new RequestContext({ - apiType: 'admin', - isAuthorized: true, - authorizedAsOwnerOnly: false, - channel, - languageCode: LanguageCode.en, - req, - }); - } - - /** - * Get the Stripe context for the current channel. - * The Stripe context consists of the Stripe client and the Vendure payment method connected to the Stripe account - */ - async getStripeContext(ctx: RequestContext): Promise { - const paymentMethods = await this.paymentMethodService.findAll(ctx, { - filter: { enabled: { eq: true } }, - }); - const stripePaymentMethods = paymentMethods.items.filter( - (pm) => pm.handler.code === stripeSubscriptionHandler.code - ); - if (stripePaymentMethods.length > 1) { - throw new UserInputError( - `Multiple payment methods found with handler 'stripe-subscription', there should only be 1 per channel!` - ); - } - const paymentMethod = stripePaymentMethods[0]; - if (!paymentMethod) { - throw new UserInputError( - `No enabled payment method found with handler 'stripe-subscription'` - ); - } - const apiKey = paymentMethod.handler.args.find( - (arg) => arg.name === 'apiKey' - )?.value; - let webhookSecret = paymentMethod.handler.args.find( - (arg) => arg.name === 'webhookSecret' - )?.value; - if (!apiKey || !webhookSecret) { - Logger.warn( - `No api key or webhook secret is configured for ${paymentMethod.code}`, - loggerCtx - ); - throw Error( - `Payment method ${paymentMethod.code} has no api key or webhook secret configured` - ); - } - return { - paymentMethod: paymentMethod, - stripeClient: new StripeClient(webhookSecret, apiKey, { - apiVersion: null as any, // Null uses accounts default version - }), - }; - } - - async logHistoryEntry( - ctx: RequestContext, - orderId: ID, - message: string, - error?: unknown, - subscription?: Subscription, - subscriptionId?: string - ): Promise { - let prettifiedError = error - ? JSON.parse(JSON.stringify(error, Object.getOwnPropertyNames(error))) - : undefined; // Make sure its serializable - await this.historyService.createHistoryEntryForOrder( - { - ctx, - orderId, - type: 'STRIPE_SUBSCRIPTION_NOTIFICATION' as any, - data: { - message, - valid: !error, - error: prettifiedError, - subscriptionId, - subscription, - }, - }, - false - ); - } -} diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe.client.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe.client.ts deleted file mode 100644 index 6c980cf2..00000000 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe.client.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { Customer } from '@vendure/core'; -import Stripe from 'stripe'; - -interface SubscriptionInput { - customerId: string; - productId: string; - currencyCode: string; - amount: number; - interval: Stripe.SubscriptionCreateParams.Item.PriceData.Recurring.Interval; - intervalCount: number; - paymentMethodId: string; - startDate: Date; - orderCode: string; - channelToken: string; - endDate?: Date; - description?: string; -} - -/** - * Wrapper around the Stripe client with specifics for this subscription plugin - */ -export class StripeClient extends Stripe { - constructor( - public webhookSecret: string, - apiKey: string, - config: Stripe.StripeConfig - ) { - super(apiKey, config); - } - - async getOrCreateCustomer(customer: Customer): Promise { - const stripeCustomers = await this.customers.list({ - email: customer.emailAddress, - }); - if (stripeCustomers.data.length > 0) { - return stripeCustomers.data[0]; - } - return await this.customers.create({ - email: customer.emailAddress, - name: `${customer.firstName} ${customer.lastName}`, - }); - } - - /** - * Throws an error if incoming webhook signature is invalid - */ - validateWebhookSignature( - payload: Buffer, - signature: string | undefined - ): void { - if (!signature) { - throw Error(`Can not validate webhook signature without a signature!`); - } - this.webhooks.constructEvent(payload, signature, this.webhookSecret); - } - - async createOffSessionSubscription({ - customerId, - productId, - currencyCode, - amount, - interval, - intervalCount, - paymentMethodId, - startDate, - endDate, - description, - orderCode, - channelToken, - }: SubscriptionInput): Promise { - return this.subscriptions.create({ - 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, - items: [ - { - price_data: { - product: productId, - currency: currencyCode, - unit_amount: amount, - recurring: { - interval: interval, - interval_count: intervalCount, - }, - }, - }, - ], - off_session: true, - default_payment_method: paymentMethodId, - payment_behavior: 'allow_incomplete', - metadata: { - orderCode, - channelToken, - }, - }); - } - - toStripeTimeStamp(date: Date): number { - return Math.round(date.getTime() / 1000); - } -} diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/subscription-order-item-calculation.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/subscription-order-item-calculation.ts deleted file mode 100644 index 70ca42f6..00000000 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/subscription-order-item-calculation.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { - Injector, - Order, - OrderItemPriceCalculationStrategy, - PriceCalculationResult, - ProductVariant, - RequestContext, - UserInputError, -} from '@vendure/core'; -import { DefaultOrderItemPriceCalculationStrategy } from '@vendure/core/dist/config/order/default-order-item-price-calculation-strategy'; -import { CustomOrderLineFields } from '@vendure/core/dist/entity/custom-entity-fields'; -import { StripeSubscriptionService } from './stripe-subscription.service'; - -let injector: Injector; - -export class SubscriptionOrderItemCalculation - extends DefaultOrderItemPriceCalculationStrategy - implements OrderItemPriceCalculationStrategy -{ - 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: ProductVariant, - orderLineCustomFields: CustomOrderLineFields, - order: Order, - orderLineQuantity: number - ): Promise { - 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 { - priceIncludesTax: subscription.priceIncludesTax, - price: subscription.amountDueNow ?? 0, - }; - } - 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-v2/types/stripe-invoice.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/types/stripe-invoice.ts deleted file mode 100644 index db4526c9..00000000 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/types/stripe-invoice.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { Metadata } from './stripe.common'; - -export interface StripeInvoice { - id: string; - object: string; - account_country: string; - account_name: string; - account_tax_ids: any; - amount_due: number; - amount_paid: number; - amount_remaining: number; - amount_shipping: number; - application: any; - application_fee_amount: any; - attempt_count: number; - attempted: boolean; - auto_advance: boolean; - automatic_tax: AutomaticTax; - billing_reason: string; - charge: any; - collection_method: string; - created: number; - currency: string; - custom_fields: any; - customer: string; - customer_address: any; - customer_email: string; - customer_name: string; - customer_phone: any; - customer_shipping: any; - customer_tax_exempt: string; - customer_tax_ids: any[]; - default_payment_method: any; - default_source: any; - default_tax_rates: any[]; - description: string; - discount: any; - discounts: any[]; - due_date: any; - effective_at: number; - ending_balance: number; - footer: any; - from_invoice: any; - hosted_invoice_url: string; - invoice_pdf: string; - last_finalization_error: any; - latest_revision: any; - lines: Lines; - livemode: boolean; - metadata: Metadata; - next_payment_attempt: any; - number: string; - on_behalf_of: any; - paid: boolean; - paid_out_of_band: boolean; - payment_intent: any; - payment_settings: PaymentSettings; - period_end: number; - period_start: number; - post_payment_credit_notes_amount: number; - pre_payment_credit_notes_amount: number; - quote: any; - receipt_number: any; - rendering_options: any; - shipping_cost: any; - shipping_details: any; - starting_balance: number; - statement_descriptor: any; - status: string; - status_transitions: StatusTransitions; - subscription: string; - subscription_details: SubscriptionDetails; - subtotal: number; - subtotal_excluding_tax: number; - tax: any; - test_clock: any; - total: number; - total_discount_amounts: any[]; - total_excluding_tax: number; - total_tax_amounts: any[]; - transfer_data: any; - webhooks_delivered_at: number; -} - -export interface AutomaticTax { - enabled: boolean; - status: any; -} - -export interface Lines { - object: string; - data: Daum[]; - has_more: boolean; - total_count: number; - url: string; -} - -export interface Daum { - id: string; - object: string; - amount: number; - amount_excluding_tax: number; - currency: string; - description: string; - discount_amounts: any[]; - discountable: boolean; - discounts: any[]; - livemode: boolean; - metadata: Metadata; - period: Period; - plan: Plan; - price: Price; - proration: boolean; - proration_details: ProrationDetails; - quantity: number; - subscription: string; - subscription_item: string; - tax_amounts: any[]; - tax_rates: any[]; - type: string; - unit_amount_excluding_tax: string; -} - -export interface Period { - end: number; - start: number; -} - -export interface Plan { - id: string; - object: string; - active: boolean; - aggregate_usage: any; - amount: number; - amount_decimal: string; - billing_scheme: string; - created: number; - currency: string; - interval: string; - interval_count: number; - livemode: boolean; - metadata: Metadata; - nickname: any; - product: string; - tiers_mode: any; - transform_usage: any; - trial_period_days: any; - usage_type: string; -} - -export interface Price { - id: string; - object: string; - active: boolean; - billing_scheme: string; - created: number; - currency: string; - custom_unit_amount: any; - livemode: boolean; - lookup_key: any; - metadata: Metadata; - nickname: any; - product: string; - recurring: Recurring; - tax_behavior: string; - tiers_mode: any; - transform_quantity: any; - type: string; - unit_amount: number; - unit_amount_decimal: string; -} - -export interface Recurring { - aggregate_usage: any; - interval: string; - interval_count: number; - trial_period_days: any; - usage_type: string; -} - -export interface ProrationDetails { - credited_items: any; -} - -export interface PaymentSettings { - default_mandate: any; - payment_method_options: any; - payment_method_types: any; -} - -export interface StatusTransitions { - finalized_at: number; - marked_uncollectible_at: any; - paid_at: number; - voided_at: any; -} - -export interface SubscriptionDetails { - metadata: Metadata; -} diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/types/stripe-payment-intent.ts b/packages/vendure-plugin-stripe-subscription/src/api-v2/types/stripe-payment-intent.ts deleted file mode 100644 index a6adb72b..00000000 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/types/stripe-payment-intent.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { Metadata } from './stripe.common'; - -export interface StripePaymentIntent { - id: string; - object: string; - amount: number; - amount_capturable: number; - amount_details: AmountDetails; - amount_received: number; - application: any; - application_fee_amount: any; - automatic_payment_methods: any; - canceled_at: any; - cancellation_reason: any; - capture_method: string; - charges: Charges; - client_secret: string; - confirmation_method: string; - created: number; - currency: string; - customer: string; - description: any; - invoice: any; - last_payment_error: any; - latest_charge: string; - livemode: boolean; - metadata: Metadata; - next_action: any; - on_behalf_of: any; - payment_method: string; - payment_method_options: PaymentMethodOptions; - payment_method_types: string[]; - processing: any; - receipt_email: string; - review: any; - setup_future_usage: string; - shipping: any; - source: any; - statement_descriptor: any; - statement_descriptor_suffix: any; - status: string; - transfer_data: any; - 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; -} - -export interface Tip {} - -export interface Charges { - object: string; - data: Daum[]; - has_more: boolean; - total_count: number; - url: string; -} - -export interface Daum { - id: string; - object: string; - amount: number; - amount_captured: number; - amount_refunded: number; - application: any; - application_fee: any; - application_fee_amount: any; - balance_transaction: string; - billing_details: BillingDetails; - calculated_statement_descriptor: string; - captured: boolean; - created: number; - currency: string; - customer: string; - description: any; - destination: any; - dispute: any; - disputed: boolean; - failure_balance_transaction: any; - failure_code: any; - failure_message: any; - fraud_details: FraudDetails; - invoice: any; - livemode: boolean; - metadata: Metadata; - on_behalf_of: any; - order: any; - outcome: Outcome; - paid: boolean; - payment_intent: string; - payment_method: string; - payment_method_details: PaymentMethodDetails; - receipt_email: string; - receipt_number: any; - receipt_url: string; - refunded: boolean; - refunds: Refunds; - review: any; - shipping: any; - source: any; - source_transfer: any; - statement_descriptor: any; - statement_descriptor_suffix: any; - status: string; - transfer_data: any; - transfer_group: any; -} - -export interface BillingDetails { - address: Address; - email: any; - name: any; - phone: any; -} - -export interface Address { - city: any; - country: string; - line1: any; - line2: any; - postal_code: any; - state: any; -} - -export interface FraudDetails {} - -export interface Outcome { - network_status: string; - reason: any; - risk_level: string; - risk_score: number; - seller_message: string; - type: string; -} - -export interface PaymentMethodDetails { - card: Card; - type: string; -} - -export interface Card { - brand: string; - checks: Checks; - country: string; - exp_month: number; - exp_year: number; - fingerprint: string; - funding: string; - installments: any; - last4: string; - mandate: any; - network: string; - network_token: NetworkToken; - three_d_secure: any; - wallet: any; -} - -export interface Checks { - address_line1_check: any; - address_postal_code_check: any; - cvc_check: string; -} - -export interface NetworkToken { - used: boolean; -} - -export interface Refunds { - object: string; - data: any[]; - has_more: boolean; - total_count: number; - url: string; -} - -export interface PaymentMethodOptions { - card: Card2; -} - -export interface Card2 { - installments: any; - mandate_options: any; - network: any; - request_three_d_secure: string; -} diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/graphql-schema.ts b/packages/vendure-plugin-stripe-subscription/src/api/graphql-schema.ts similarity index 100% rename from packages/vendure-plugin-stripe-subscription/src/api-v2/graphql-schema.ts rename to packages/vendure-plugin-stripe-subscription/src/api/graphql-schema.ts 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/has-stripe-subscription-products-payment-checker.ts b/packages/vendure-plugin-stripe-subscription/src/api/has-stripe-subscription-products-payment-checker.ts deleted file mode 100644 index cac656ae..00000000 --- a/packages/vendure-plugin-stripe-subscription/src/api/has-stripe-subscription-products-payment-checker.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { - LanguageCode, - Order, - PaymentMethodEligibilityChecker, -} from '@vendure/core'; -import { OrderWithSubscriptionFields } from './subscription-custom-fields'; - -export function hasSubscriptions(order: Order): boolean { - return (order as OrderWithSubscriptionFields).lines.some( - (line) => line.productVariant.customFields.subscriptionSchedule - ); -} - -export const hasStripeSubscriptionProductsPaymentChecker = - new PaymentMethodEligibilityChecker({ - code: 'has-stripe-subscription-products-checker', - description: [ - { - languageCode: LanguageCode.en, - value: 'Checks that the order has Stripe Subscription products.', - }, - ], - args: {}, - check: (ctx, order, args) => { - if (hasSubscriptions(order)) { - return true; - } - return false; - }, - }); 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-v2/strategy/default-subscription-strategy.ts b/packages/vendure-plugin-stripe-subscription/src/api/strategy/default-subscription-strategy.ts similarity index 94% rename from packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/default-subscription-strategy.ts rename to packages/vendure-plugin-stripe-subscription/src/api/strategy/default-subscription-strategy.ts index 79c1a6eb..cfdc968c 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/default-subscription-strategy.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api/strategy/default-subscription-strategy.ts @@ -45,7 +45,7 @@ export class DefaultSubscriptionStrategy implements SubscriptionStrategy { name: `Subscription ${productVariant.name}`, variantId: productVariant.id, priceIncludesTax: productVariant.listPriceIncludesTax, - amountDueNow: price, + amountDueNow: 0, recurring: { amount: price, interval: 'month', @@ -57,6 +57,6 @@ export class DefaultSubscriptionStrategy implements SubscriptionStrategy { private getOneMonthFromNow(): Date { var now = new Date(); - return new Date(now.getFullYear(), now.getMonth() + 1, 1); + return new Date(now.getFullYear(), now.getMonth() + 1, now.getDate(), 12); } } diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/subscription-strategy.ts b/packages/vendure-plugin-stripe-subscription/src/api/strategy/subscription-strategy.ts similarity index 100% rename from packages/vendure-plugin-stripe-subscription/src/api-v2/strategy/subscription-strategy.ts rename to packages/vendure-plugin-stripe-subscription/src/api/strategy/subscription-strategy.ts 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.handler.ts b/packages/vendure-plugin-stripe-subscription/src/api/stripe-subscription.handler.ts deleted file mode 100644 index 6bc2ae23..00000000 --- a/packages/vendure-plugin-stripe-subscription/src/api/stripe-subscription.handler.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { - CreatePaymentResult, - CreateRefundResult, - Injector, - LanguageCode, - Logger, - PaymentMethodHandler, - SettlePaymentResult, -} from '@vendure/core'; -import { loggerCtx } from '../constants'; -import { printMoney } from './pricing.helper'; -import { StripeSubscriptionService } from './stripe-subscription.service'; - -let service: StripeSubscriptionService; -export const stripeSubscriptionHandler = new PaymentMethodHandler({ - code: 'stripe-subscription', - - description: [ - { - languageCode: LanguageCode.en, - value: 'Use a Stripe Subscription as payment', - }, - ], - - args: { - apiKey: { - type: 'string', - label: [{ languageCode: LanguageCode.en, value: 'Stripe API key' }], - ui: { component: 'password-form-input' }, - }, - publishableKey: { - type: 'string', - required: false, - label: [ - { languageCode: LanguageCode.en, value: 'Stripe publishable key' }, - ], - description: [ - { - languageCode: LanguageCode.en, - value: - 'You can retrieve this via the "eligiblePaymentMethods.stripeSubscriptionPublishableKey" query in the shop api', - }, - ], - }, - webhookSecret: { - type: 'string', - label: [ - { - languageCode: LanguageCode.en, - value: 'Webhook secret', - }, - ], - description: [ - { - languageCode: LanguageCode.en, - value: - 'Secret to validate incoming webhooks. Get this from your Stripe dashboard', - }, - ], - ui: { component: 'password-form-input' }, - }, - }, - - init(injector: Injector) { - service = injector.get(StripeSubscriptionService); - }, - - async createPayment( - ctx, - order, - amount, - _, - metadata - ): Promise { - // Payment is already settled in Stripe by the time the webhook in stripe.controller.ts - // adds the payment to the order - if (ctx.apiType !== 'admin') { - throw Error(`CreatePayment is not allowed for apiType '${ctx.apiType}'`); - } - return { - amount: metadata.amount, - state: 'Settled', - transactionId: metadata.setupIntentId, - metadata, - }; - }, - settlePayment(): SettlePaymentResult { - // Payments will be settled via webhook - return { - success: true, - }; - }, - - async createRefund( - ctx, - input, - amount, - order, - payment, - args - ): Promise { - const { stripeClient } = await service.getStripeContext(ctx); - const refund = await stripeClient.refunds.create({ - payment_intent: payment.transactionId, - amount, - }); - Logger.info( - `Refund of ${printMoney(amount)} created for payment ${ - payment.transactionId - } for order ${order.id}`, - loggerCtx - ); - await service.logHistoryEntry( - ctx, - order.id, - `Created refund of ${printMoney(amount)}` - ); - return { - state: 'Settled', - metadata: refund, - }; - }, -}); diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.resolver.ts b/packages/vendure-plugin-stripe-subscription/src/api/stripe-subscription.resolver.ts similarity index 100% rename from packages/vendure-plugin-stripe-subscription/src/api-v2/stripe-subscription.resolver.ts rename to packages/vendure-plugin-stripe-subscription/src/api/stripe-subscription.resolver.ts 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..7a1aacb1 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,27 @@ 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 { 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 { 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 +78,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 +148,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 +160,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,15 +171,15 @@ 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 orderLinesWithSubscriptions = 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) => @@ -191,21 +191,138 @@ export class StripeSubscriptionService { ) ); }); + // 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).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 + ): Promise | undefined> { + const webhookDescription = `Vendure Stripe Subscription Webhook for channel ${ctx.channel.token}`; + const { stripeClient, paymentMethod } = await this.getStripeContext(ctx); + 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; + } else { + return [subscriptions]; + } + } + + async previewSubscriptionForProduct( + ctx: RequestContext, + productId: ID, + customInputs?: any + ): Promise { + const product = await this.productService.findOne(ctx, productId, [ + 'variants', + ]); + if (!product) { + throw new UserInputError(`No product with id '${product}' found`); + } + const injector = new Injector(this.moduleRef); + const subscriptions = await Promise.all( + product.variants.map((variant) => + this.strategy.previewSubscription(ctx, injector, variant, customInputs) + ) + ); + // Flatten, because there can be multiple subscriptions per variant, resulting in [[sub, sub], sub, sub] + 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 +387,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 +418,117 @@ 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( + async defineSubscriptions( 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( - ctx: RequestContext, - eventType: string, - object: StripeInvoice - ): Promise { - const stripeSubscriptionPaymentRepo = this.connection.getRepository( - ctx, - StripeSubscriptionPayment + order: Order + ): Promise<(Subscription & { orderLineId: 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) => ({ ...sub, orderLineId: line.id })); + } + return { + orderLineId: line.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 +540,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 +556,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 +586,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 +594,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 +620,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 +644,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` - ); + throw Error(`[${loggerCtx}]: Cannot find order with code ${orderCode}`); } - 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` - ); - } - 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, + // + const subscriptionsPerOrderLine = new Map(); + for (const subscriptionDefinition of subscriptionDefinitions) { + try { + const product = await stripeClient.products.create({ + name: subscriptionDefinition.name, }); - 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 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}'`, + `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, - '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}`, - 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 +837,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 +853,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-v2/types/stripe.common.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-v2/types/stripe.common.ts rename to packages/vendure-plugin-stripe-subscription/src/api/types/stripe.common.ts diff --git a/packages/vendure-plugin-stripe-subscription/src/api/types/stripe.types.ts b/packages/vendure-plugin-stripe-subscription/src/api/types/stripe.types.ts deleted file mode 100644 index 1555efa5..00000000 --- a/packages/vendure-plugin-stripe-subscription/src/api/types/stripe.types.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { StripeInvoice } from './stripe-invoice'; -import { StripePaymentIntent } from './stripe-payment-intent'; - -export interface Metadata { - orderCode: string; - channelToken: string; - paymentMethodCode: string; - amount: number; -} - -export interface Data { - object: StripeInvoice | StripePaymentIntent; -} - -export interface Request { - id?: any; - idempotency_key?: any; -} - -export interface IncomingStripeWebhook { - id: string; - object: string; - api_version: string; - created: number; - data: Data; - livemode: boolean; - pending_webhooks: number; - request: Request; - type: string; -} diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/util.ts b/packages/vendure-plugin-stripe-subscription/src/api/util.ts similarity index 100% rename from packages/vendure-plugin-stripe-subscription/src/api-v2/util.ts rename to packages/vendure-plugin-stripe-subscription/src/api/util.ts diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/custom-fields.ts b/packages/vendure-plugin-stripe-subscription/src/api/vendure-config/custom-fields.ts similarity index 100% rename from packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/custom-fields.ts rename to packages/vendure-plugin-stripe-subscription/src/api/vendure-config/custom-fields.ts diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/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 100% rename from packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/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 diff --git a/packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/stripe-subscription.handler.ts b/packages/vendure-plugin-stripe-subscription/src/api/vendure-config/stripe-subscription.handler.ts similarity index 87% rename from packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/stripe-subscription.handler.ts rename to packages/vendure-plugin-stripe-subscription/src/api/vendure-config/stripe-subscription.handler.ts index a4395501..f26d1f6f 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api-v2/vendure-config/stripe-subscription.handler.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api/vendure-config/stripe-subscription.handler.ts @@ -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, diff --git a/packages/vendure-plugin-stripe-subscription/src/index.ts b/packages/vendure-plugin-stripe-subscription/src/index.ts index fcce9408..604241c9 100644 --- a/packages/vendure-plugin-stripe-subscription/src/index.ts +++ b/packages/vendure-plugin-stripe-subscription/src/index.ts @@ -1,8 +1,9 @@ -export * from './api-v2/generated/graphql'; -export * from './api-v2/stripe-subscription.service'; -export * from './api-v2/strategy/subscription-strategy'; -export * from './api-v2/strategy/default-subscription-strategy'; -export * from './api-v2/vendure-config/has-stripe-subscription-products-payment-checker'; -export * from './api-v2/vendure-config/stripe-subscription.handler'; +export * from './stripe-subscription.plugin'; +export * from './api/generated/graphql'; +export * from './api/stripe-subscription.service'; +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-v2/stripe.client'; +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 5f61a255..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,18 +1,18 @@ import { PluginCommonModule, VendurePlugin } from '@vendure/core'; import { PLUGIN_INIT_OPTIONS } from './constants'; -import { SubscriptionStrategy } from './api-v2/strategy/subscription-strategy'; -import { shopSchemaExtensions } from './api-v2/graphql-schema'; +import { SubscriptionStrategy } from './api/strategy/subscription-strategy'; +import { shopSchemaExtensions } from './api/graphql-schema'; import { createRawBodyMiddleWare } from '../../util/src/raw-body'; -import { DefaultSubscriptionStrategy } from './api-v2/strategy/default-subscription-strategy'; +import { DefaultSubscriptionStrategy } from './api/strategy/default-subscription-strategy'; import path from 'path'; import { AdminUiExtension } from '@vendure/ui-devkit/compiler'; -import { orderLineCustomFields } from './api-v2/vendure-config/custom-fields'; -import { stripeSubscriptionHandler } from './api-v2/vendure-config/stripe-subscription.handler'; -import { hasStripeSubscriptionProductsPaymentChecker } from './api-v2/vendure-config/has-stripe-subscription-products-payment-checker'; -import { SubscriptionOrderItemCalculation } from './api-v2/subscription-order-item-calculation'; -import { StripeSubscriptionService } from './api-v2/stripe-subscription.service'; -import { StripeSubscriptionShopResolver } from './api-v2/stripe-subscription.resolver'; -import { StripeSubscriptionController } from './api-v2/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 { StripeSubscriptionShopResolver } from './api/stripe-subscription.resolver'; +import { StripeSubscriptionController } from './api/stripe-subscription.controller'; export interface StripeSubscriptionPluginOptions { /** @@ -69,6 +69,7 @@ export class StripeSubscriptionPlugin { } static ui: AdminUiExtension = { + id: 'stripe-subscription-extension', extensionPath: path.join(__dirname, 'ui'), ngModules: [ { 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 4dec4309..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 { - - + + `, }) 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 9e6f2b5d..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,12 +5,10 @@ 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: [ registerHistoryEntryComponent({ type: 'STRIPE_SUBSCRIPTION_NOTIFICATION', diff --git a/packages/vendure-plugin-stripe-subscription/test/dev-server.ts b/packages/vendure-plugin-stripe-subscription/test/dev-server.ts index 20171cd3..c8813bf9 100644 --- a/packages/vendure-plugin-stripe-subscription/test/dev-server.ts +++ b/packages/vendure-plugin-stripe-subscription/test/dev-server.ts @@ -11,18 +11,23 @@ import { registerInitializer, SqljsInitializer, } from '@vendure/testing'; -import { StripeSubscriptionPlugin } from '../src/stripe-subscription.plugin'; +import { compileUiExtensions } from '@vendure/ui-devkit/compiler'; +import { + StripeSubscriptionPlugin, + StripeSubscriptionIntent, + DefaultSubscriptionStrategy, +} 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 path from 'path'; -export let clientSecret = 'test'; +export let intent: StripeSubscriptionIntent; /** * Use something like NGROK to start a reverse tunnel to receive webhooks: ngrok http 3050 @@ -37,6 +42,11 @@ 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: {}, @@ -45,21 +55,22 @@ export let clientSecret = 'test'; StripeTestCheckoutPlugin, StripeSubscriptionPlugin.init({ vendureHost: process.env.VENDURE_HOST!, + subscriptionStrategy: new DefaultSubscriptionStrategy(), }), DefaultSearchPlugin, AdminUiPlugin.init({ port: 3002, route: 'admin', - // app: process.env.COMPILE_ADMIN - // ? compileUiExtensions({ - // outputPath: path.join(__dirname, '__admin-ui'), - // extensions: [StripeSubscriptionPlugin.ui], - // devMode: true, - // }) - // : // otherwise used precompiled files. Might need to run once using devMode: false - // { - // path: path.join(__dirname, '__admin-ui/dist'), - // }, + app: process.env.COMPILE_ADMIN + ? compileUiExtensions({ + outputPath: path.join(__dirname, '__admin-ui'), + extensions: [StripeSubscriptionPlugin.ui], + devMode: true, + }) + : // otherwise used precompiled files. Might need to run once using devMode: false + { + path: path.join(__dirname, '__admin-ui/dist'), + }, }), ], }); @@ -96,6 +107,7 @@ export let clientSecret = 'test'; value: process.env.STRIPE_WEBHOOK_SECRET, }, { name: 'apiKey', value: process.env.STRIPE_APIKEY }, + { name: 'publishableKey', value: process.env.STRIPE_PUBLISHABLE_KEY }, ], }, translations: [ @@ -118,12 +130,12 @@ export let clientSecret = 'test'; await setShipping(shopClient); console.log(`Prepared order ${order?.code}`); - const { - createStripeSubscriptionIntent: { clientSecret: secret, intentType }, - } = await shopClient.query(CREATE_PAYMENT_LINK); - clientSecret = secret; + const { createStripeSubscriptionIntent } = await shopClient.query( + CREATE_PAYMENT_LINK + ); + intent = createStripeSubscriptionIntent; console.log( - `Go to http://localhost:3050/checkout/ to test your ${intentType}` + `Go to http://localhost:3050/checkout/ to test your ${intent.intentType}` ); // Uncomment these lines to list all subscriptions created in Stripe diff --git a/packages/vendure-plugin-stripe-subscription/test/dev-server_backup.ts b/packages/vendure-plugin-stripe-subscription/test/dev-server_backup.ts deleted file mode 100644 index f1818456..00000000 --- a/packages/vendure-plugin-stripe-subscription/test/dev-server_backup.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { - createTestEnvironment, - registerInitializer, - SqljsInitializer, -} from '@vendure/testing'; -import { - DefaultLogger, - DefaultSearchPlugin, - LanguageCode, - LogLevel, - mergeConfig, - RequestContextService, -} from '@vendure/core'; -import { AdminUiPlugin } from '@vendure/admin-ui-plugin'; -import { StripeTestCheckoutPlugin } from './stripe-test-checkout.plugin'; -import { - ADD_ITEM_TO_ORDER, - CREATE_PAYMENT_LINK, - CREATE_PAYMENT_METHOD, - setShipping, - UPDATE_CHANNEL, - UPDATE_VARIANT, -} from './helpers'; - -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'; - -/** - * Use something like NGROK to start a reverse tunnel to receive webhooks: ngrok http 3050 - * Set the generated url as webhook endpoint in your Stripe account: https://8837-85-145-210-58.eu.ngrok.io/stripe-subscriptions/webhook - * Make sure it listens for all checkout events. This can be configured in Stripe when setting the webhook - * Now, you are ready to `yarn start` - * The logs will display a link that can be used to subscribe via Stripe - */ -(async () => { - require('dotenv').config(); - const { testConfig } = require('@vendure/testing'); - registerInitializer('sqljs', new SqljsInitializer('__data__')); - const config = mergeConfig(testConfig, { - logger: new DefaultLogger({ level: LogLevel.Debug }), - apiOptions: { - adminApiPlayground: {}, - shopApiPlayground: {}, - }, - plugins: [ - StripeTestCheckoutPlugin, - StripeSubscriptionPlugin, - DefaultSearchPlugin, - AdminUiPlugin.init({ - port: 3002, - route: 'admin', - app: process.env.COMPILE_ADMIN - ? compileUiExtensions({ - outputPath: path.join(__dirname, '__admin-ui'), - extensions: [StripeSubscriptionPlugin.ui], - devMode: true, - }) - : // otherwise used precompiled files. Might need to run once using devMode: false - { - path: path.join(__dirname, '__admin-ui/dist'), - }, - }), - ], - }); - const { server, shopClient, adminClient } = createTestEnvironment(config); - await server.init({ - initialData: { - ...require('../../test/src/initial-data').initialData, - shippingMethods: [{ name: 'Standard Shipping', price: 0 }], - }, - productsCsvPath: `${__dirname}/subscriptions.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, - // }, - // }, - // ], - // }); - console.log(`Added schedule to variants`); - // Prepare order - await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test'); - - // This is the variant for checkout - /* await shopClient.query(ADD_ITEM_TO_ORDER, { - productVariantId: '2', - quantity: 1, - customFields: { - // downpayment: 40000, - // startDate: in3Days, - }, - }); */ - 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`); - - // 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)); -})(); 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..bdfb2937 100644 --- a/packages/vendure-plugin-stripe-subscription/test/stripe-subscription.spec.ts +++ b/packages/vendure-plugin-stripe-subscription/test/stripe-subscription.spec.ts @@ -23,32 +23,10 @@ import nock from 'nock'; 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, @@ -108,6 +86,8 @@ describe('Stripe Subscription Plugin', function () { const ctx = { channel: { pricesIncludeTax: true } }; + // TODO test webhook registrations + it("Sets channel settings to 'prices are including tax'", async () => { await adminClient.asSuperAdmin(); const { 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 fd1befce..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,7 +1,7 @@ import { PluginCommonModule, VendurePlugin } from '@vendure/core'; 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 @@ -14,6 +14,8 @@ export class CheckoutController { @Res() res: Response, @Body() body: any ): Promise { + const confirmMethod = + intent.intentType === 'SetupIntent' ? 'confirmSetup' : 'confirmPayment'; res.send(` Checkout @@ -36,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: {/*...*/}, }; @@ -52,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/tsconfig.json b/packages/vendure-plugin-stripe-subscription/tsconfig.json index c3bdf656..306af4f7 100644 --- a/packages/vendure-plugin-stripe-subscription/tsconfig.json +++ b/packages/vendure-plugin-stripe-subscription/tsconfig.json @@ -5,5 +5,5 @@ "types": ["node"] }, "include": ["src/"], - "exclude": ["src/ui", "src/api"] + "exclude": ["src/ui"] } From 32c74b0be89f609472b4654955ec30342d4e2079 Mon Sep 17 00:00:00 2001 From: Martijn Date: Wed, 1 Nov 2023 12:43:00 +0100 Subject: [PATCH 15/23] fix(stripe-subscription): added example downpayment strategy --- .../strategy/default-subscription-strategy.ts | 3 +- .../test/dev-server.ts | 8 +- .../test/downpayment-subscription-strategy.ts | 89 +++++++++++++++++++ 3 files changed, 94 insertions(+), 6 deletions(-) create mode 100644 packages/vendure-plugin-stripe-subscription/test/downpayment-subscription-strategy.ts 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 index cfdc968c..ba906e40 100644 --- 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 @@ -32,7 +32,8 @@ export class DefaultSubscriptionStrategy implements SubscriptionStrategy { previewSubscription( ctx: RequestContext, injector: Injector, - productVariant: ProductVariant + productVariant: ProductVariant, + customInputs: any ): Subscription { return this.getSubscriptionForVariant(productVariant); } diff --git a/packages/vendure-plugin-stripe-subscription/test/dev-server.ts b/packages/vendure-plugin-stripe-subscription/test/dev-server.ts index c8813bf9..5c41fa5b 100644 --- a/packages/vendure-plugin-stripe-subscription/test/dev-server.ts +++ b/packages/vendure-plugin-stripe-subscription/test/dev-server.ts @@ -26,6 +26,7 @@ import { } from './helpers'; import { StripeTestCheckoutPlugin } from './stripe-test-checkout.plugin'; import path from 'path'; +import { DownPaymentSubscriptionStrategy } from './downpayment-subscription-strategy'; export let intent: StripeSubscriptionIntent; @@ -55,7 +56,7 @@ export let intent: StripeSubscriptionIntent; StripeTestCheckoutPlugin, StripeSubscriptionPlugin.init({ vendureHost: process.env.VENDURE_HOST!, - subscriptionStrategy: new DefaultSubscriptionStrategy(), + subscriptionStrategy: new DownPaymentSubscriptionStrategy(), }), DefaultSearchPlugin, AdminUiPlugin.init({ @@ -67,10 +68,7 @@ export let intent: StripeSubscriptionIntent; 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, }), ], }); 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..30c76dd3 --- /dev/null +++ b/packages/vendure-plugin-stripe-subscription/test/downpayment-subscription-strategy.ts @@ -0,0 +1,89 @@ +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 of the subscription: + * * The variant's price is the price per month + * * Down payment will be created as a subscription that renews every X months, based on the given duration + * + * This strategy expects the customfields `subscriptionDownpayment` and `subscriptionDurationInMonths` to be set on the order line. + * + * For previewing subscriptions, the customInputs can be used: `customInputs: {subscriptionDownpayment: 100, subscriptionDuration: 12}` + */ +export class DownPaymentSubscriptionStrategy implements SubscriptionStrategy { + 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, + orderLineCustomFields.subscriptionDurationInMonths + ); + } + + previewSubscription( + ctx: RequestContext, + injector: Injector, + productVariant: ProductVariant, + customInputs: { + subscriptionDownpayment: number; + subscriptionDurationInMonths: number; + } + ): Subscription[] { + return this.getSubscriptionsForVariant( + productVariant, + customInputs.subscriptionDownpayment, + customInputs.subscriptionDurationInMonths + ); + } + + private getSubscriptionsForVariant( + productVariant: ProductVariant, + downpayment: number, + durationInMonths: number + ): Subscription[] { + const discountPerMonth = downpayment / durationInMonths; + return [ + { + 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(), + }, + }, + { + name: `Downpayment subscription - ${productVariant.name}`, + variantId: productVariant.id, + priceIncludesTax: productVariant.listPriceIncludesTax, + amountDueNow: 0, + recurring: { + amount: downpayment, + interval: 'month', + intervalCount: durationInMonths, + startDate: new Date(), + }, + }, + ]; + } +} From 6c131d8e868aba89fe0697c946b789cf3bd6d0ef Mon Sep 17 00:00:00 2001 From: Martijn Date: Wed, 1 Nov 2023 13:00:55 +0100 Subject: [PATCH 16/23] fix(stripe-subscription): downpayment example --- .../test/downpayment-subscription-strategy.ts | 47 +++++++++---------- 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/packages/vendure-plugin-stripe-subscription/test/downpayment-subscription-strategy.ts b/packages/vendure-plugin-stripe-subscription/test/downpayment-subscription-strategy.ts index 30c76dd3..4b77a3f6 100644 --- a/packages/vendure-plugin-stripe-subscription/test/downpayment-subscription-strategy.ts +++ b/packages/vendure-plugin-stripe-subscription/test/downpayment-subscription-strategy.ts @@ -8,15 +8,13 @@ import { import { Subscription, SubscriptionStrategy } from '../src/'; /** - * This strategy creates a monthly subscription + a recurring down payment based on the duration of the subscription: + * 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 X months, based on the given duration - * - * This strategy expects the customfields `subscriptionDownpayment` and `subscriptionDurationInMonths` to be set on the order line. - * - * For previewing subscriptions, the customInputs can be used: `customInputs: {subscriptionDownpayment: 100, subscriptionDuration: 12}` + * * 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; @@ -33,7 +31,7 @@ export class DownPaymentSubscriptionStrategy implements SubscriptionStrategy { return this.getSubscriptionsForVariant( productVariant, orderLineCustomFields.subscriptionDownpayment, - orderLineCustomFields.subscriptionDurationInMonths + this.durationInMonths ); } @@ -43,13 +41,12 @@ export class DownPaymentSubscriptionStrategy implements SubscriptionStrategy { productVariant: ProductVariant, customInputs: { subscriptionDownpayment: number; - subscriptionDurationInMonths: number; } ): Subscription[] { return this.getSubscriptionsForVariant( productVariant, customInputs.subscriptionDownpayment, - customInputs.subscriptionDurationInMonths + this.durationInMonths ); } @@ -59,20 +56,21 @@ export class DownPaymentSubscriptionStrategy implements SubscriptionStrategy { durationInMonths: number ): Subscription[] { const discountPerMonth = downpayment / durationInMonths; - return [ - { - 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(), - }, + 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, @@ -83,7 +81,8 @@ export class DownPaymentSubscriptionStrategy implements SubscriptionStrategy { intervalCount: durationInMonths, startDate: new Date(), }, - }, - ]; + }); + } + return subscriptions; } } From f189b8153ba600dac311b1d2588dc80cfaf20598 Mon Sep 17 00:00:00 2001 From: Martijn Date: Wed, 1 Nov 2023 13:01:24 +0100 Subject: [PATCH 17/23] v2.0.0-beta-1 --- packages/vendure-plugin-stripe-subscription/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vendure-plugin-stripe-subscription/package.json b/packages/vendure-plugin-stripe-subscription/package.json index ef03c11c..12579444 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-beta-1", "description": "Vendure plugin for selling subscriptions via Stripe", "author": "Martijn van de Brug ", "homepage": "https://pinelab-plugins.com/", From 2a85a89adad931a9990117608317bd80340b0ca3 Mon Sep 17 00:00:00 2001 From: Martijn Date: Wed, 1 Nov 2023 15:37:23 +0100 Subject: [PATCH 18/23] v2.0.0-beta-2 --- packages/vendure-plugin-stripe-subscription/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vendure-plugin-stripe-subscription/package.json b/packages/vendure-plugin-stripe-subscription/package.json index 12579444..3c0a7aef 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": "2.0.0-beta-1", + "version": "2.0.0-beta-2", "description": "Vendure plugin for selling subscriptions via Stripe", "author": "Martijn van de Brug ", "homepage": "https://pinelab-plugins.com/", From de5fcc3f7f852b4063e0734086c49b76023a6f4b Mon Sep 17 00:00:00 2001 From: Martijn Date: Thu, 2 Nov 2023 15:03:40 +0100 Subject: [PATCH 19/23] feat(stripe-subscription): added e2e tests --- .../README.md | 6 +- .../src/api/graphql-schema.ts | 4 +- .../strategy/default-subscription-strategy.ts | 4 +- .../src/api/stripe-subscription.resolver.ts | 13 +- .../src/api/stripe-subscription.service.ts | 34 +- .../test/dev-server.ts | 25 +- .../test/helpers.ts | 198 +-- .../test/stripe-subscription.spec.ts | 1186 ++++------------- .../test/subscriptions.csv | 4 - 9 files changed, 352 insertions(+), 1122 deletions(-) delete mode 100644 packages/vendure-plugin-stripe-subscription/test/subscriptions.csv diff --git a/packages/vendure-plugin-stripe-subscription/README.md b/packages/vendure-plugin-stripe-subscription/README.md index 50b58f3e..014973e0 100644 --- a/packages/vendure-plugin-stripe-subscription/README.md +++ b/packages/vendure-plugin-stripe-subscription/README.md @@ -69,7 +69,7 @@ plugins: [ ```graphql { - previewStripeSubscription(productVariantId: 1) { + previewStripeSubscriptions(productVariantId: 1) { name amountDueNow variantId @@ -85,7 +85,7 @@ plugins: [ } ``` -2. The same can be done for all variants of a product with the query `previewStripeSubscriptionForProduct` +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. @@ -195,7 +195,7 @@ You can pass custom inputs to your strategy, to change how a subscription is def ```graphql { - previewStripeSubscriptionForProduct( + previewStripeSubscriptionsForProduct( productVariantId: 1 customInputs: { subscriptionStartDate: "2024-01-01" } ) { diff --git a/packages/vendure-plugin-stripe-subscription/src/api/graphql-schema.ts b/packages/vendure-plugin-stripe-subscription/src/api/graphql-schema.ts index f7552c62..2b2c5d18 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api/graphql-schema.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api/graphql-schema.ts @@ -46,11 +46,11 @@ export const shopSchemaExtensions = gql` } extend type Query { - previewStripeSubscription( + previewStripeSubscriptions( productVariantId: ID! customInputs: JSON ): [StripeSubscription!]! - previewStripeSubscriptionForProduct( + previewStripeSubscriptionsForProduct( productId: ID! customInputs: JSON ): [StripeSubscription!]! 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 index ba906e40..3eee703d 100644 --- 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 @@ -46,7 +46,7 @@ export class DefaultSubscriptionStrategy implements SubscriptionStrategy { name: `Subscription ${productVariant.name}`, variantId: productVariant.id, priceIncludesTax: productVariant.listPriceIncludesTax, - amountDueNow: 0, + amountDueNow: price, recurring: { amount: price, interval: 'month', @@ -57,7 +57,7 @@ export class DefaultSubscriptionStrategy implements SubscriptionStrategy { } private getOneMonthFromNow(): Date { - var now = new 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/stripe-subscription.resolver.ts b/packages/vendure-plugin-stripe-subscription/src/api/stripe-subscription.resolver.ts index 5e16ca5b..4439613d 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api/stripe-subscription.resolver.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api/stripe-subscription.resolver.ts @@ -10,22 +10,17 @@ import { PaymentMethodQuote } from '@vendure/common/lib/generated-shop-types'; import { Allow, Ctx, - ID, - OrderService, PaymentMethodService, Permission, - ProductService, RequestContext, UserInputError, } from '@vendure/core'; import { Request } from 'express'; import { + Mutation as GraphqlMutation, + Query as GraphqlQuery, QueryPreviewStripeSubscriptionArgs, QueryPreviewStripeSubscriptionForProductArgs, - StripeSubscription, - StripeSubscriptionIntent, - Query as GraphqlQuery, - Mutation as GraphqlMutation, } from './generated/graphql'; import { StripeSubscriptionService } from './stripe-subscription.service'; @@ -48,7 +43,7 @@ export class StripeSubscriptionShopResolver { } @Query() - async previewStripeSubscription( + async previewStripeSubscriptions( @Ctx() ctx: RequestContext, @Args() { productVariantId, customInputs }: QueryPreviewStripeSubscriptionArgs @@ -61,7 +56,7 @@ export class StripeSubscriptionShopResolver { } @Query() - async previewStripeSubscriptionForProduct( + async previewStripeSubscriptionsForProduct( @Ctx() ctx: RequestContext, @Args() { productId, customInputs }: QueryPreviewStripeSubscriptionForProductArgs 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 7a1aacb1..7e349a5e 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 @@ -175,18 +175,18 @@ export class StripeSubscriptionService { | Cancellation | Release )[]; - const orderLinesWithSubscriptions = cancelOrReleaseEvents + const stockEvents = cancelOrReleaseEvents // Filter out non-sub orderlines .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, }) ) ); @@ -196,7 +196,7 @@ export class StripeSubscriptionService { if (event.type === 'created' || event.type === 'updated') { const paymentMethod = event.entity; if (paymentMethod.handler.code === stripeSubscriptionHandler.code) { - await this.registerWebhooks(event.ctx).catch((e) => { + await this.registerWebhooks(event.ctx, paymentMethod).catch((e) => { Logger.error( `Failed to register webhooks for channel ${event.ctx.channel.token}: ${e}`, loggerCtx @@ -215,10 +215,21 @@ export class StripeSubscriptionService { * Saves the webhook secret irectly on the payment method */ async registerWebhooks( - ctx: RequestContext + ctx: RequestContext, + paymentMethod: PaymentMethod ): Promise | undefined> { const webhookDescription = `Vendure Stripe Subscription Webhook for channel ${ctx.channel.token}`; - const { stripeClient, paymentMethod } = await this.getStripeContext(ctx); + 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 }); @@ -292,15 +303,14 @@ export class StripeSubscriptionService { productId: ID, customInputs?: any ): Promise { - const product = await this.productService.findOne(ctx, productId, [ - 'variants', - ]); - if (!product) { - throw new UserInputError(`No product with id '${product}' found`); + const { items: variants } = + await this.productVariantService.getVariantsByProductId(ctx, productId); + if (!variants?.length) { + throw new UserInputError(`No variants for product '${productId}' found`); } const injector = new Injector(this.moduleRef); const subscriptions = await Promise.all( - product.variants.map((variant) => + variants.map((variant) => this.strategy.previewSubscription(ctx, injector, variant, customInputs) ) ); diff --git a/packages/vendure-plugin-stripe-subscription/test/dev-server.ts b/packages/vendure-plugin-stripe-subscription/test/dev-server.ts index 5c41fa5b..292b1796 100644 --- a/packages/vendure-plugin-stripe-subscription/test/dev-server.ts +++ b/packages/vendure-plugin-stripe-subscription/test/dev-server.ts @@ -12,21 +12,15 @@ import { SqljsInitializer, } from '@vendure/testing'; import { compileUiExtensions } from '@vendure/ui-devkit/compiler'; -import { - StripeSubscriptionPlugin, - StripeSubscriptionIntent, - DefaultSubscriptionStrategy, -} from '../src/'; +import path from 'path'; +import { StripeSubscriptionIntent, StripeSubscriptionPlugin } from '../src/'; import { ADD_ITEM_TO_ORDER, CREATE_PAYMENT_LINK, CREATE_PAYMENT_METHOD, setShipping, - UPDATE_CHANNEL, } from './helpers'; import { StripeTestCheckoutPlugin } from './stripe-test-checkout.plugin'; -import path from 'path'; -import { DownPaymentSubscriptionStrategy } from './downpayment-subscription-strategy'; export let intent: StripeSubscriptionIntent; @@ -56,7 +50,7 @@ export let intent: StripeSubscriptionIntent; StripeTestCheckoutPlugin, StripeSubscriptionPlugin.init({ vendureHost: process.env.VENDURE_HOST!, - subscriptionStrategy: new DownPaymentSubscriptionStrategy(), + // subscriptionStrategy: new DownPaymentSubscriptionStrategy(), }), DefaultSearchPlugin, AdminUiPlugin.init({ @@ -78,19 +72,8 @@ export let intent: StripeSubscriptionIntent; ...require('../../test/src/initial-data').initialData, shippingMethods: [{ name: 'Standard Shipping', price: 0 }], }, - productsCsvPath: `${__dirname}/subscriptions.csv`, - }); - // Set channel prices to include tax - await adminClient.asSuperAdmin(); - const { - updateChannel: { id }, - } = await adminClient.query(UPDATE_CHANNEL, { - input: { - id: 'T_1', - pricesIncludeTax: true, - }, + productsCsvPath: '../test/src/products-import.csv', }); - console.log('Update channel prices to include tax'); // Create stripe payment method await adminClient.asSuperAdmin(); await adminClient.query(CREATE_PAYMENT_METHOD, { diff --git a/packages/vendure-plugin-stripe-subscription/test/helpers.ts b/packages/vendure-plugin-stripe-subscription/test/helpers.ts index 71d50ae9..ed0a5d65 100644 --- a/packages/vendure-plugin-stripe-subscription/test/helpers.ts +++ b/packages/vendure-plugin-stripe-subscription/test/helpers.ts @@ -9,6 +9,8 @@ export const ADD_ITEM_TO_ORDER = gql` ... on Order { id code + totalWithTax + total } ... on ErrorResult { errorCode @@ -18,117 +20,31 @@ 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 CREATE_PAYMENT_METHOD = gql` + mutation CreatePaymentMethod($input: CreatePaymentMethodInput!) { + createPaymentMethod(input: $input) { + id } } `; -export const UPDATE_CHANNEL = gql` - mutation UpdateChannel($input: UpdateChannelInput!) { - updateChannel(input: $input) { - ... on Channel { - id +export const GET_PAYMENT_METHODS = gql` + query paymentMethods { + paymentMethods { + items { + code + handler { + args { + name + value + __typename + } + } } } } `; -// 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 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 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) { @@ -177,48 +93,64 @@ export const CREATE_PAYMENT_LINK = gql` } `; -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 bdfb2937..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,35 +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 { stripeSubscriptionHandler, StripeSubscriptionPlugin } from '../src'; import { ADD_ITEM_TO_ORDER, + CANCEL_ORDER, CREATE_PAYMENT_LINK, CREATE_PAYMENT_METHOD, - 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__')); @@ -54,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) @@ -84,971 +90,279 @@ describe('Stripe Subscription Plugin', function () { }); }); - const ctx = { channel: { pricesIncludeTax: true } }; - - // TODO test webhook registrations - - 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/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 , , From dde758021ff1abf80125deaa86134698ad98d933 Mon Sep 17 00:00:00 2001 From: Martijn Date: Thu, 2 Nov 2023 15:09:15 +0100 Subject: [PATCH 20/23] feat(stripe-subscription): version and changelog update --- packages/vendure-plugin-stripe-subscription/CHANGELOG.md | 7 +++++++ packages/vendure-plugin-stripe-subscription/package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) 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/package.json b/packages/vendure-plugin-stripe-subscription/package.json index 3c0a7aef..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": "2.0.0-beta-2", + "version": "2.0.0", "description": "Vendure plugin for selling subscriptions via Stripe", "author": "Martijn van de Brug ", "homepage": "https://pinelab-plugins.com/", From ec41aa5519b44760382aae9ba01c856c29390629 Mon Sep 17 00:00:00 2001 From: Martijn Date: Thu, 2 Nov 2023 15:39:40 +0100 Subject: [PATCH 21/23] feat(stripe-subscription): dev-server perpare nad readme typo --- .../README.md | 7 +- .../test/dev-server.ts | 94 +++++++++++-------- 2 files changed, 57 insertions(+), 44 deletions(-) diff --git a/packages/vendure-plugin-stripe-subscription/README.md b/packages/vendure-plugin-stripe-subscription/README.md index 014973e0..b28394dc 100644 --- a/packages/vendure-plugin-stripe-subscription/README.md +++ b/packages/vendure-plugin-stripe-subscription/README.md @@ -92,10 +92,9 @@ plugins: [ 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. -9. The customer can now enter his credit card credentials. -10. Vendure will create the subscriptions in the background, after the intent has successfully been completed by the customer. -11. The order will be settled by Vendure when the subscriptions are created. +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. It's important to inform your customers what you will be billing them in the future: https://stripe.com/docs/payments/setup-intents#mandates diff --git a/packages/vendure-plugin-stripe-subscription/test/dev-server.ts b/packages/vendure-plugin-stripe-subscription/test/dev-server.ts index 292b1796..572f2058 100644 --- a/packages/vendure-plugin-stripe-subscription/test/dev-server.ts +++ b/packages/vendure-plugin-stripe-subscription/test/dev-server.ts @@ -5,6 +5,7 @@ import { LanguageCode, LogLevel, mergeConfig, + RequestContextService, } from '@vendure/core'; import { createTestEnvironment, @@ -13,7 +14,11 @@ import { } from '@vendure/testing'; import { compileUiExtensions } from '@vendure/ui-devkit/compiler'; import path from 'path'; -import { StripeSubscriptionIntent, StripeSubscriptionPlugin } from '../src/'; +import { + StripeSubscriptionIntent, + StripeSubscriptionPlugin, + StripeSubscriptionService, +} from '../src/'; import { ADD_ITEM_TO_ORDER, CREATE_PAYMENT_LINK, @@ -75,32 +80,32 @@ export let intent: StripeSubscriptionIntent; productsCsvPath: '../test/src/products-import.csv', }); // 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 }, - { 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(`Created paymentMethod stripe-subscription`); + // 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(`Created paymentMethod stripe-subscription`); await shopClient.asUserWithCredentials('hayden.zieme12@hotmail.com', 'test'); let { addItemToOrder: order } = await shopClient.query(ADD_ITEM_TO_ORDER, { @@ -108,19 +113,28 @@ export let intent: StripeSubscriptionIntent; quantity: 1, }); - await setShipping(shopClient); - console.log(`Prepared order ${order?.code}`); + // 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}` - ); + // 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' + ); })(); From 93951a79052944f65f499c08cab944a6ad37244b Mon Sep 17 00:00:00 2001 From: Martijn Date: Thu, 2 Nov 2023 15:41:55 +0100 Subject: [PATCH 22/23] feat(stripe-subscription): imports broken --- .../src/api/stripe-subscription.resolver.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 index 4439613d..376e1d8a 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api/stripe-subscription.resolver.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api/stripe-subscription.resolver.ts @@ -19,8 +19,8 @@ import { Request } from 'express'; import { Mutation as GraphqlMutation, Query as GraphqlQuery, - QueryPreviewStripeSubscriptionArgs, - QueryPreviewStripeSubscriptionForProductArgs, + QueryPreviewStripeSubscriptionsArgs, + QueryPreviewStripeSubscriptionsForProductArgs, } from './generated/graphql'; import { StripeSubscriptionService } from './stripe-subscription.service'; @@ -46,8 +46,8 @@ export class StripeSubscriptionShopResolver { async previewStripeSubscriptions( @Ctx() ctx: RequestContext, @Args() - { productVariantId, customInputs }: QueryPreviewStripeSubscriptionArgs - ): Promise { + { productVariantId, customInputs }: QueryPreviewStripeSubscriptionsArgs + ): Promise { return this.stripeSubscriptionService.previewSubscription( ctx, productVariantId, @@ -59,8 +59,8 @@ export class StripeSubscriptionShopResolver { async previewStripeSubscriptionsForProduct( @Ctx() ctx: RequestContext, @Args() - { productId, customInputs }: QueryPreviewStripeSubscriptionForProductArgs - ): Promise { + { productId, customInputs }: QueryPreviewStripeSubscriptionsForProductArgs + ): Promise { return this.stripeSubscriptionService.previewSubscriptionForProduct( ctx, productId, From 0054c5e73236545860dd339c0fd51bad4b5a669e Mon Sep 17 00:00:00 2001 From: Martijn Date: Thu, 2 Nov 2023 15:59:01 +0100 Subject: [PATCH 23/23] feat(stripe-subscription): small cleanup --- .../README.md | 6 --- .../strategy/default-subscription-strategy.ts | 1 - .../src/api/strategy/subscription-strategy.ts | 1 - .../src/api/stripe-subscription.service.ts | 37 +++++++++++++------ 4 files changed, 25 insertions(+), 20 deletions(-) diff --git a/packages/vendure-plugin-stripe-subscription/README.md b/packages/vendure-plugin-stripe-subscription/README.md index b28394dc..ff37fcdd 100644 --- a/packages/vendure-plugin-stripe-subscription/README.md +++ b/packages/vendure-plugin-stripe-subscription/README.md @@ -140,7 +140,6 @@ export class MySubscriptionStrategy implements SubscriptionStrategy { ): Subscription { return { name: `Subscription ${productVariant.name}`, - variantId: productVariant.id, priceIncludesTax: productVariant.listPriceIncludesTax, amountDueNow: productVariant.listPrice, recurring: { @@ -162,7 +161,6 @@ export class MySubscriptionStrategy implements SubscriptionStrategy { ): Subscription { return { name: `Subscription ${productVariant.name}`, - variantId: productVariant.id, priceIncludesTax: productVariant.listPriceIncludesTax, amountDueNow: productVariant.listPrice, recurring: { @@ -224,7 +222,6 @@ You can pass custom inputs to your strategy, to change how a subscription is def ): Subscription { return { name: `Subscription ${productVariant.name}`, - variantId: productVariant.id, priceIncludesTax: productVariant.listPriceIncludesTax, amountDueNow: productVariant.listPrice, recurring: { @@ -251,7 +248,6 @@ You can pass custom inputs to your strategy, to change how a subscription is def ): Subscription { return { name: `Subscription ${productVariant.name}`, - variantId: productVariant.id, priceIncludesTax: productVariant.listPriceIncludesTax, amountDueNow: productVariant.listPrice, recurring: { @@ -279,7 +275,6 @@ Example: A customer pays $90 a month, but is also required to pay a yearly fee o return [ { name: `Monthly fee`, - variantId: productVariant.id, priceIncludesTax: productVariant.listPriceIncludesTax, amountDueNow: 0, recurring: { @@ -290,7 +285,6 @@ Example: A customer pays $90 a month, but is also required to pay a yearly fee o }, }, { name: `yearly fee`, - variantId: productVariant.id, priceIncludesTax: productVariant.listPriceIncludesTax, amountDueNow: 0, recurring: { 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 index 3eee703d..9c287f5d 100644 --- 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 @@ -44,7 +44,6 @@ export class DefaultSubscriptionStrategy implements SubscriptionStrategy { const price = productVariant.listPrice; return { name: `Subscription ${productVariant.name}`, - variantId: productVariant.id, priceIncludesTax: productVariant.listPriceIncludesTax, amountDueNow: price, recurring: { 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 index 0decca00..3c76fd37 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api/strategy/subscription-strategy.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api/strategy/subscription-strategy.ts @@ -15,7 +15,6 @@ export interface Subscription { * Name for displaying purposes */ name: string; - variantId: ID; amountDueNow: number; priceIncludesTax: boolean; recurring: { 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 7e349a5e..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 @@ -33,12 +33,16 @@ import { 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 { 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 { StripeSubscriptionIntent } from './generated/graphql'; +import { + StripeSubscription, + StripeSubscriptionIntent, +} from './generated/graphql'; import { Subscription, SubscriptionStrategy, @@ -274,7 +278,7 @@ export class StripeSubscriptionService { ctx: RequestContext, productVariantId: ID, customInputs?: any - ): Promise { + ): Promise { const variant = await this.productVariantService.findOne( ctx, productVariantId @@ -292,9 +296,17 @@ export class StripeSubscriptionService { customInputs ); if (Array.isArray(subscriptions)) { - return subscriptions; + return subscriptions.map((sub) => ({ + ...sub, + variantId: variant.id, + })); } else { - return [subscriptions]; + return [ + { + ...subscriptions, + variantId: variant.id, + }, + ]; } } @@ -302,19 +314,15 @@ export class StripeSubscriptionService { ctx: RequestContext, productId: ID, customInputs?: any - ): Promise { + ): Promise { const { items: variants } = await this.productVariantService.getVariantsByProductId(ctx, productId); if (!variants?.length) { throw new UserInputError(`No variants for product '${productId}' found`); } - const injector = new Injector(this.moduleRef); const subscriptions = await Promise.all( - variants.map((variant) => - this.strategy.previewSubscription(ctx, injector, variant, customInputs) - ) + variants.map((v) => this.previewSubscription(ctx, v.id, customInputs)) ); - // Flatten, because there can be multiple subscriptions per variant, resulting in [[sub, sub], sub, sub] return subscriptions.flat(); } @@ -492,7 +500,7 @@ export class StripeSubscriptionService { async defineSubscriptions( ctx: RequestContext, order: Order - ): Promise<(Subscription & { orderLineId: ID })[]> { + ): 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) => @@ -510,10 +518,15 @@ export class StripeSubscriptionService { ); // Add orderlineId to subscription if (Array.isArray(subs)) { - return subs.map((sub) => ({ ...sub, orderLineId: line.id })); + return subs.map((sub) => ({ + orderLineId: line.id, + variantId: line.productVariant.id, + ...sub, + })); } return { orderLineId: line.id, + variantId: line.productVariant.id, ...subs, }; })