diff --git a/packages/vendure-plugin-stripe-subscription/codegen.yml b/packages/vendure-plugin-stripe-subscription/codegen.yml index 54b45919..6bdd6d76 100644 --- a/packages/vendure-plugin-stripe-subscription/codegen.yml +++ b/packages/vendure-plugin-stripe-subscription/codegen.yml @@ -5,6 +5,7 @@ generates: plugins: - typescript - typescript-operations + - typed-document-node config: avoidOptionals: false scalars: diff --git a/packages/vendure-plugin-stripe-subscription/package.json b/packages/vendure-plugin-stripe-subscription/package.json index 9946b917..a1de9cf8 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.1.1", + "version": "1.2.0", "description": "Vendure plugin for selling subscriptions via Stripe", "author": "Martijn van de Brug ", "homepage": "https://pinelab-plugins.com/", diff --git a/packages/vendure-plugin-stripe-subscription/src/api/graphql-schemas.ts b/packages/vendure-plugin-stripe-subscription/src/api/graphql-schemas.ts index b6362f39..c834138a 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api/graphql-schemas.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api/graphql-schemas.ts @@ -7,6 +7,17 @@ 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 @@ -18,7 +29,10 @@ const sharedTypes = gql` time_of_purchase fixed_startdate } - type StripeSubscriptionSchedule { + """ + For codegen to work this must implement Node + """ + type StripeSubscriptionSchedule implements Node { id: ID! createdAt: DateTime updatedAt: DateTime @@ -35,6 +49,21 @@ const sharedTypes = gql` 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! @@ -113,9 +142,39 @@ export const adminSchemaExtensions = gql` 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: [StripeSubscriptionSchedule!]! + stripeSubscriptionSchedules( + options: StripeSubscriptionScheduleListOptions + ): StripeSubscriptionScheduleList! + stripeSubscriptionPayments( + options: StripeSubscriptionPaymentListOptions + ): StripeSubscriptionPaymentList! } + extend type Mutation { upsertStripeSubscriptionSchedule( input: UpsertStripeSubscriptionScheduleInput! diff --git a/packages/vendure-plugin-stripe-subscription/src/api/schedule.service.ts b/packages/vendure-plugin-stripe-subscription/src/api/schedule.service.ts index 26b4e366..8aeacd4a 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api/schedule.service.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api/schedule.service.ts @@ -1,12 +1,15 @@ import { Injectable } from '@nestjs/common'; import { ID, + ListQueryBuilder, RequestContext, TransactionalConnection, UserInputError, } from '@vendure/core'; import { StripeSubscriptionSchedule, + StripeSubscriptionScheduleList, + StripeSubscriptionScheduleListOptions, SubscriptionStartMoment, UpsertStripeSubscriptionScheduleInput, } from '../ui/generated/graphql'; @@ -15,18 +18,24 @@ import { Schedule } from './schedule.entity'; @Injectable() export class ScheduleService { - constructor(private connection: TransactionalConnection) {} + constructor( + private listQueryBuilder: ListQueryBuilder, + private connection: TransactionalConnection + ) {} async getSchedules( - ctx: RequestContext - ): Promise { - const schedules = await this.connection - .getRepository(ctx, Schedule) - .find({ where: { channelId: String(ctx.channelId) } }); - - return schedules.map((schedule) => { - return cloneSchedule(ctx, schedule); - }); + 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( 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 new file mode 100644 index 00000000..7fc487d9 --- /dev/null +++ b/packages/vendure-plugin-stripe-subscription/src/api/stripe-subscription-payment.entity.ts @@ -0,0 +1,34 @@ +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 ab696f36..61cfd2a4 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 @@ -24,18 +24,24 @@ 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 { IncomingStripeWebhook } from './stripe.types'; 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'; export type RequestWithRawBody = Request & { rawBody: any }; @@ -141,14 +147,27 @@ export class AdminPriceIncludesTaxResolver { @Resolver() export class AdminResolver { - constructor(private scheduleService: ScheduleService) {} + constructor( + private stripeSubscriptionService: StripeSubscriptionService, + private scheduleService: ScheduleService + ) {} @Allow(Permission.ReadSettings) @Query() async stripeSubscriptionSchedules( - @Ctx() ctx: RequestContext - ): Promise { - return this.scheduleService.getSchedules(ctx); + @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) @@ -188,11 +207,11 @@ export class StripeSubscriptionController { Logger.info(`Incoming webhook ${body.type}`, loggerCtx); // Validate if metadata present const orderCode = - body.data.object.metadata?.orderCode || - body.data.object.lines?.data[0]?.metadata.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.lines?.data[0]?.metadata.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' && @@ -234,21 +253,45 @@ export class StripeSubscriptionController { if (body.type === 'payment_intent.succeeded') { await this.stripeSubscriptionService.handlePaymentIntentSucceeded( ctx, - body, + body.data.object as StripePaymentIntent, order ); } else if (body.type === 'invoice.payment_succeeded') { + const invoiceObject = body.data.object as StripeInvoice; await this.stripeSubscriptionService.handleInvoicePaymentSucceeded( ctx, - body, + 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') { + const invoiceObject = body.data.object as StripeInvoice; await this.stripeSubscriptionService.handleInvoicePaymentFailed( ctx, - body, + 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.service.ts b/packages/vendure-plugin-stripe-subscription/src/api/stripe-subscription.service.ts index d1f1a5f8..2210cc0e 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 @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { StockMovementType } from '@vendure/common/lib/generated-types'; import { ActiveOrderService, + Channel, ChannelService, CustomerService, EntityHydrator, @@ -9,15 +10,19 @@ import { EventBus, HistoryService, ID, + InternalServerError, JobQueue, JobQueueService, LanguageCode, + ListQueryBuilder, + ListQueryOptions, Logger, Order, OrderLine, OrderLineEvent, OrderService, OrderStateTransitionError, + PaginatedList, PaymentMethodService, ProductVariantService, RequestContext, @@ -27,7 +32,7 @@ import { UserInputError, } from '@vendure/core'; import { loggerCtx } from '../constants'; -import { IncomingStripeWebhook } from './stripe.types'; +import { IncomingStripeWebhook } from './types/stripe.types'; import { CustomerWithSubscriptionFields, OrderLineWithSubscriptionFields, @@ -36,6 +41,8 @@ import { } from './subscription-custom-fields'; import { StripeClient } from './stripe.client'; import { + StripeSubscriptionPaymentList, + StripeSubscriptionPaymentListOptions, StripeSubscriptionPricing, StripeSubscriptionPricingInput, } from '../ui/generated/graphql'; @@ -52,6 +59,9 @@ import { Cancellation } from '@vendure/core/dist/entity/stock-movement/cancellat 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'; export interface StripeHandlerConfig { paymentMethodCode: string; @@ -84,6 +94,7 @@ export class StripeSubscriptionService { private entityHydrator: EntityHydrator, private channelService: ChannelService, private orderService: OrderService, + private listQueryBuilder: ListQueryBuilder, private historyService: HistoryService, private eventBus: EventBus, private jobQueueService: JobQueueService, @@ -392,12 +403,54 @@ export class StripeSubscriptionService { }; } + async savePaymentEvent( + ctx: RequestContext, + eventType: string, + object: StripeInvoice + ): Promise { + const stripeSubscriptionPaymentRepo = this.connection.getRepository( + ctx, + StripeSubscriptionPayment + ); + const charge = object.lines.data.reduce( + (acc, line) => acc + (line.plan?.amount ?? 0), + 0 + ); + 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, + }); + await stripeSubscriptionPaymentRepo.save(newPayment); + } + + async getPaymentEvents( + ctx: RequestContext, + options: StripeSubscriptionPaymentListOptions + ): Promise { + return this.listQueryBuilder + .build(StripeSubscriptionPayment, options, { ctx }) + .getManyAndCount() + .then(([items, totalItems]) => ({ + items, + totalItems, + })); + } + /** * Handle future subscription payments that come in after the initial payment intent */ async handleInvoicePaymentSucceeded( ctx: RequestContext, - { data: { object } }: IncomingStripeWebhook, + object: StripeInvoice, order: Order ): Promise { const amount = object.lines?.data?.[0]?.plan?.amount; @@ -419,7 +472,7 @@ export class StripeSubscriptionService { */ async handleInvoicePaymentFailed( ctx: RequestContext, - { data: { object } }: IncomingStripeWebhook, + object: StripeInvoice, order: Order ): Promise { const amount = object.lines?.data[0]?.plan?.amount; @@ -442,11 +495,11 @@ export class StripeSubscriptionService { */ async handlePaymentIntentSucceeded( ctx: RequestContext, - { data: { object: eventData } }: IncomingStripeWebhook, + object: StripePaymentIntent, order: Order ): Promise { const { paymentMethodCode } = await this.getStripeHandler(ctx, order.id); - if (!eventData.customer) { + if (!object.customer) { await this.logHistoryEntry( ctx, order.id, @@ -462,8 +515,8 @@ export class StripeSubscriptionService { action: 'createSubscriptionsForOrder', ctx: ctx.serialize(), orderCode: order.code, - stripePaymentMethodId: eventData.payment_method, - stripeCustomerId: eventData.customer, + stripePaymentMethodId: object.payment_method, + stripeCustomerId: object.customer, }, { retries: 0 } // Only 1 try, because subscription creation isn't transaction-proof ) @@ -492,8 +545,8 @@ export class StripeSubscriptionService { { method: paymentMethodCode, metadata: { - setupIntentId: eventData.id, - amount: eventData.metadata.amount, + setupIntentId: object.id, + amount: object.metadata.amount, }, } ); 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 new file mode 100644 index 00000000..92349250 --- /dev/null +++ b/packages/vendure-plugin-stripe-subscription/src/api/types/stripe-invoice.ts @@ -0,0 +1,200 @@ +import { Metadata } from './stripe.types'; + +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/types/stripe-payment-intent.ts b/packages/vendure-plugin-stripe-subscription/src/api/types/stripe-payment-intent.ts new file mode 100644 index 00000000..9d2722a0 --- /dev/null +++ b/packages/vendure-plugin-stripe-subscription/src/api/types/stripe-payment-intent.ts @@ -0,0 +1,186 @@ +import { Metadata } from './stripe.types'; + +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/stripe.types.ts b/packages/vendure-plugin-stripe-subscription/src/api/types/stripe.types.ts similarity index 60% rename from packages/vendure-plugin-stripe-subscription/src/api/stripe.types.ts rename to packages/vendure-plugin-stripe-subscription/src/api/types/stripe.types.ts index cbd42ccf..1555efa5 100644 --- a/packages/vendure-plugin-stripe-subscription/src/api/stripe.types.ts +++ b/packages/vendure-plugin-stripe-subscription/src/api/types/stripe.types.ts @@ -1,3 +1,6 @@ +import { StripeInvoice } from './stripe-invoice'; +import { StripePaymentIntent } from './stripe-payment-intent'; + export interface Metadata { orderCode: string; channelToken: string; @@ -5,25 +8,8 @@ export interface Metadata { amount: number; } -export interface Object { - id: string; - object: string; - customer: string; - payment_method: string; - metadata: Metadata; - subscription: string; - lines?: { - data: { - metadata: Metadata; - plan: { - amount: number; - }; - }[]; - }; -} - export interface Data { - object: Object; + object: StripeInvoice | StripePaymentIntent; } export interface Request { diff --git a/packages/vendure-plugin-stripe-subscription/src/index.ts b/packages/vendure-plugin-stripe-subscription/src/index.ts index c8860e60..d575e490 100644 --- a/packages/vendure-plugin-stripe-subscription/src/index.ts +++ b/packages/vendure-plugin-stripe-subscription/src/index.ts @@ -5,7 +5,7 @@ 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/stripe.types'; +export * from './api/types/stripe.types'; export * from './api/stripe-subscription.controller'; export * from './api/schedule.entity'; export * from './api/subscription.promotion'; 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 59c05cb3..9cbce5ad 100644 --- a/packages/vendure-plugin-stripe-subscription/src/stripe-subscription.plugin.ts +++ b/packages/vendure-plugin-stripe-subscription/src/stripe-subscription.plugin.ts @@ -26,6 +26,7 @@ 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'; export interface StripeSubscriptionPluginOptions { /** @@ -36,7 +37,7 @@ export interface StripeSubscriptionPluginOptions { @VendurePlugin({ imports: [PluginCommonModule], - entities: [Schedule], + entities: [Schedule, StripeSubscriptionPayment], shopApiExtensions: { schema: shopSchemaExtensions, resolvers: [ShopResolver, ShopOrderLinePricingResolver], @@ -86,8 +87,8 @@ export class StripeSubscriptionPlugin { ngModules: [ { type: 'lazy', - route: 'subscription-schedules', - ngModuleFileName: 'schedules.module.ts', + route: 'stripe', + ngModuleFileName: 'stripe-subscription.module.ts', ngModuleName: 'SchedulesModule', }, { 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 new file mode 100644 index 00000000..50693ac3 --- /dev/null +++ b/packages/vendure-plugin-stripe-subscription/src/ui/payments-component/payments-component.html @@ -0,0 +1,101 @@ + + + + + {{ 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 new file mode 100644 index 00000000..1147f8c6 --- /dev/null +++ b/packages/vendure-plugin-stripe-subscription/src/ui/payments-component/payments.component.ts @@ -0,0 +1,81 @@ +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 index 575a80e5..001c162c 100644 --- a/packages/vendure-plugin-stripe-subscription/src/ui/queries.ts +++ b/packages/vendure-plugin-stripe-subscription/src/ui/queries.ts @@ -19,11 +19,41 @@ export const SCHEDULE_FRAGMENT = gql` } `; +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 { - ...ScheduleFields + items { + ...ScheduleFields + } + totalItems + } + } +`; + +export const GET_PAYMENTS = gql` + ${PAYMENT_FRAGMENT} + query stripeSubscriptionPayments { + stripeSubscriptionPayments { + items { + ...PaymentFields + } + totalItems } } `; 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 new file mode 100644 index 00000000..f1c6dc2a --- /dev/null +++ b/packages/vendure-plugin-stripe-subscription/src/ui/schedules-component/schedules.component.html @@ -0,0 +1,347 @@ + + + +

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.scss b/packages/vendure-plugin-stripe-subscription/src/ui/schedules-component/schedules.component.scss similarity index 100% rename from packages/vendure-plugin-stripe-subscription/src/ui/schedules.component.scss rename to packages/vendure-plugin-stripe-subscription/src/ui/schedules-component/schedules.component.scss 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 new file mode 100644 index 00000000..9ebcc2b3 --- /dev/null +++ b/packages/vendure-plugin-stripe-subscription/src/ui/schedules-component/schedules.component.ts @@ -0,0 +1,324 @@ +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/schedules.component.ts b/packages/vendure-plugin-stripe-subscription/src/ui/schedules.component.ts deleted file mode 100644 index 959a9dfb..00000000 --- a/packages/vendure-plugin-stripe-subscription/src/ui/schedules.component.ts +++ /dev/null @@ -1,476 +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 { DELETE_SCHEDULE, GET_SCHEDULES, UPSERT_SCHEDULES } from './queries'; -import { - StripeSubscriptionSchedule, - SubscriptionInterval, - SubscriptionStartMoment, -} from './generated/graphql'; - -@Component({ - selector: 'stripe-subscription-component', - styleUrls: ['./schedules.component.scss'], - template: ` -

Stripe Subscription Schedules

-

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

-
- -
- - - {{ schedule.id }} - {{ schedule.name }} - - {{ schedule.createdAt | date }} - - - - - - - - - -
-
-
-

- {{ - selectedSchedule?.id - ? 'Edit schedule ' + selectedSchedule?.id - : 'Create new schedule' - }} -

- -
-
- -
-
- - - - - - - - - - - - - - - - every - - - - - - - - - - of the - {{ - form.value.isPaidUpFront - ? form.value.durationInterval - : form.value.billingInterval - }} - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
- `, -}) -export class SchedulesComponent implements OnInit { - 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 - ) { - 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], - }); - } - get now() { - return new Date().toISOString(); - } - - async ngOnInit(): Promise { - await this.fetchSchedules(); - 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.selectedSchedule = this.schedules.find((s) => s.id === scheduleId); - 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 { - await this.fetchSchedules(); - } - } - - 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/schedules.module.ts b/packages/vendure-plugin-stripe-subscription/src/ui/schedules.module.ts deleted file mode 100644 index c3be74ad..00000000 --- a/packages/vendure-plugin-stripe-subscription/src/ui/schedules.module.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { NgModule } from '@angular/core'; -import { RouterModule } from '@angular/router'; -import { SharedModule } from '@vendure/admin-ui/core'; -import { SchedulesComponent } from './schedules.component'; - -@NgModule({ - imports: [ - SharedModule, - RouterModule.forChild([ - { - path: '', - pathMatch: 'full', - component: SchedulesComponent, - data: { breadcrumb: 'Subscription schedules' }, - }, - ]), - ], - providers: [], - declarations: [SchedulesComponent], -}) -export class SchedulesModule {} 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 dc78070c..2bddccd8 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 @@ -22,14 +22,24 @@ import { HistoryEntryComponent } from './history-entry.component'; }), addNavMenuItem( { - id: 'stripe-subscription-schedules', - label: 'Subscriptions', - routerLink: ['/extensions/subscription-schedules'], + id: 'subscription-schedules', + label: 'Subscriptions schedules', + routerLink: ['/extensions/stripe/subscription-schedules'], icon: 'calendar', requiresPermission: 'UpdateSettings', }, 'settings' ), + addNavMenuItem( + { + id: 'subscription-payments', + label: 'Subscriptions payments', + routerLink: ['/extensions/stripe/subscription-payments'], + icon: 'dollar', + requiresPermission: 'ReadOrder', + }, + 'sales' + ), ], }) export class StripeSubscriptionSharedModule {} diff --git a/packages/vendure-plugin-stripe-subscription/src/ui/stripe-subscription.module.ts b/packages/vendure-plugin-stripe-subscription/src/ui/stripe-subscription.module.ts new file mode 100644 index 00000000..50eccf1d --- /dev/null +++ b/packages/vendure-plugin-stripe-subscription/src/ui/stripe-subscription.module.ts @@ -0,0 +1,26 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { SharedModule } from '@vendure/admin-ui/core'; +import { SchedulesComponent } from './schedules-component/schedules.component'; +import { PaymentsComponent } from './payments-component/payments.component'; + +@NgModule({ + imports: [ + SharedModule, + RouterModule.forChild([ + { + path: 'subscription-schedules', + component: SchedulesComponent, + data: { breadcrumb: 'Subscription schedules' }, + }, + { + path: 'subscription-payments', + component: PaymentsComponent, + data: { breadcrumb: 'Subscription payments' }, + }, + ]), + ], + providers: [], + declarations: [SchedulesComponent, PaymentsComponent], +}) +export class SchedulesModule {} diff --git a/packages/vendure-plugin-stripe-subscription/test/dev-server.ts b/packages/vendure-plugin-stripe-subscription/test/dev-server.ts index 5df1011f..5e059cfb 100644 --- a/packages/vendure-plugin-stripe-subscription/test/dev-server.ts +++ b/packages/vendure-plugin-stripe-subscription/test/dev-server.ts @@ -194,7 +194,7 @@ export let clientSecret = 'test'; }, }); */ let { addItemToOrder: order } = await shopClient.query(ADD_ITEM_TO_ORDER, { - productVariantId: '3', + productVariantId: '2', quantity: 1, customFields: { // downpayment: 40000, diff --git a/packages/vendure-plugin-stripe-subscription/test/helpers.ts b/packages/vendure-plugin-stripe-subscription/test/helpers.ts index 5dea8a4c..9469f660 100644 --- a/packages/vendure-plugin-stripe-subscription/test/helpers.ts +++ b/packages/vendure-plugin-stripe-subscription/test/helpers.ts @@ -186,17 +186,19 @@ export const CREATE_PAYMENT_LINK = gql` export const GET_SCHEDULES = gql` { stripeSubscriptionSchedules { - id - createdAt - updatedAt - name - downpayment - durationInterval - durationCount - startMoment - paidUpFront - billingInterval - billingCount + items { + id + createdAt + updatedAt + name + downpayment + durationInterval + durationCount + startMoment + paidUpFront + billingInterval + billingCount + } } } `; 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 a90405e5..23c2129a 100644 --- a/packages/vendure-plugin-stripe-subscription/test/stripe-subscription.spec.ts +++ b/packages/vendure-plugin-stripe-subscription/test/stripe-subscription.spec.ts @@ -36,6 +36,7 @@ import { Schedule, StripeSubscriptionPlugin, StripeSubscriptionPricing, + StripeSubscriptionService, SubscriptionInterval, SubscriptionStartMoment, VariantForCalculation, @@ -58,6 +59,7 @@ import { 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; @@ -834,7 +836,7 @@ describe('Stripe Subscription Plugin', function () { }); it('Logs payments to order history', async () => { - await adminClient.fetch( + const result = await adminClient.fetch( 'http://localhost:3050/stripe-subscriptions/webhook', { method: 'POST', @@ -862,6 +864,7 @@ describe('Stripe Subscription Plugin', function () { 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' @@ -869,6 +872,14 @@ describe('Stripe Subscription Plugin', function () { ).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[] = []; @@ -971,8 +982,8 @@ describe('Stripe Subscription Plugin', function () { await adminClient.asSuperAdmin(); const { stripeSubscriptionSchedules: schedules } = await adminClient.query(GET_SCHEDULES); - expect(schedules[0]).toBeDefined(); - expect(schedules[0].id).toBeDefined(); + expect(schedules.items[0]).toBeDefined(); + expect(schedules.items[0].id).toBeDefined(); }); it('Can delete Schedules', async () => { @@ -993,7 +1004,7 @@ describe('Stripe Subscription Plugin', function () { const { stripeSubscriptionSchedules: schedules } = await adminClient.query(GET_SCHEDULES); expect( - schedules.find((s: any) => s.id == toBeDeleted.id) + schedules.items.find((s: any) => s.id == toBeDeleted.id) ).toBeUndefined(); });