From d86f4344c8fddcce6a1e6d48c94cb386d954a903 Mon Sep 17 00:00:00 2001 From: Martijn Date: Tue, 14 Jan 2025 11:41:29 +0100 Subject: [PATCH] feat(accept-blue): fix pagination for blogs --- .../vendure-plugin-accept-blue/CHANGELOG.md | 4 + .../vendure-plugin-accept-blue/package.json | 2 +- .../src/api/accept-blue-client.ts | 46 ++++- .../src/api/accept-blue-service.ts | 55 +++++- .../vendure-plugin-accept-blue/src/types.ts | 6 +- .../test/accept-blue.spec.ts | 160 +++++++++++------- .../test/dev-server.ts | 11 ++ .../helpers/test-subscription-strategy.ts | 1 - 8 files changed, 207 insertions(+), 78 deletions(-) diff --git a/packages/vendure-plugin-accept-blue/CHANGELOG.md b/packages/vendure-plugin-accept-blue/CHANGELOG.md index 091ce6f7..8936016f 100644 --- a/packages/vendure-plugin-accept-blue/CHANGELOG.md +++ b/packages/vendure-plugin-accept-blue/CHANGELOG.md @@ -1,3 +1,7 @@ +# 2.2.0 (2025-01-14) + +- Update a customer's shipping and billing details in Accept Blue on subscription creation + # 2.1.0 (2025-01-10) - Allow updating created subscriptions via the Admin API diff --git a/packages/vendure-plugin-accept-blue/package.json b/packages/vendure-plugin-accept-blue/package.json index 4924b2f6..3251b3ce 100644 --- a/packages/vendure-plugin-accept-blue/package.json +++ b/packages/vendure-plugin-accept-blue/package.json @@ -1,6 +1,6 @@ { "name": "@pinelab/vendure-plugin-accept-blue", - "version": "2.1.0", + "version": "2.2.0", "description": "Vendure plugin for creating subscriptions with the Accept Blue platform", "author": "Martijn van de Brug ", "homepage": "https://pinelab-plugins.com/", diff --git a/packages/vendure-plugin-accept-blue/src/api/accept-blue-client.ts b/packages/vendure-plugin-accept-blue/src/api/accept-blue-client.ts index be8bc8c7..663c6ed2 100644 --- a/packages/vendure-plugin-accept-blue/src/api/accept-blue-client.ts +++ b/packages/vendure-plugin-accept-blue/src/api/accept-blue-client.ts @@ -5,17 +5,18 @@ import { loggerCtx } from '../constants'; import { AcceptBlueChargeTransaction, AcceptBlueCustomer, + AcceptBlueCustomerInput, AcceptBluePaymentMethod, AcceptBlueRecurringSchedule, AcceptBlueRecurringScheduleCreateInput, AcceptBlueRecurringScheduleTransaction, - CheckPaymentMethodInput, - NoncePaymentMethodInput, + AcceptBlueRecurringScheduleUpdateInput, AcceptBlueTransaction, - AcceptBlueWebhookInput, AcceptBlueWebhook, + AcceptBlueWebhookInput, + CheckPaymentMethodInput, CustomFields, - AcceptBlueRecurringScheduleUpdateInput, + NoncePaymentMethodInput, } from '../types'; import { isSameCard, isSameCheck } from '../util'; @@ -53,13 +54,27 @@ export class AcceptBlueClient { return await this.request('get', `transactions/${id}`); } - async getOrCreateCustomer(emailAddress: string): Promise { + /** + * Find a customer based on given emailAddress and updates the details. + * If no customer found, creates a new customer. + */ + async upsertCustomer( + emailAddress: string, + input: AcceptBlueCustomerInput + ): Promise { const existing = await this.getCustomer(emailAddress); if (existing) { + await this.updateCustomer(existing.id, input).catch((e) => { + // Catch and log instead of throw, because an existing customer was already found to return + Logger.error( + `Failed to update customer ${existing.id}: ${e}`, + loggerCtx + ); + }); return existing; } else { Logger.info(`Creating new customer ${emailAddress}`, loggerCtx); - return await this.createCustomer(emailAddress); + return await this.createCustomer(emailAddress, input); } } @@ -85,8 +100,12 @@ export class AcceptBlueClient { return undefined; } - async createCustomer(emailAddress: string): Promise { - const customer: Partial = { + async createCustomer( + emailAddress: string, + input: AcceptBlueCustomerInput + ): Promise { + const customer: AcceptBlueCustomerInput = { + ...input, identifier: emailAddress, customer_number: emailAddress, email: emailAddress, @@ -99,6 +118,17 @@ export class AcceptBlueClient { return result; } + async updateCustomer( + id: number, + input: AcceptBlueCustomerInput + ): Promise { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const result = await this.request('patch', `customers/${id}`, input); + Logger.info(`Updated customer '${id}'`, loggerCtx); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return result; + } + async getOrCreatePaymentMethod( acceptBlueCustomerId: number, input: NoncePaymentMethodInput | CheckPaymentMethodInput diff --git a/packages/vendure-plugin-accept-blue/src/api/accept-blue-service.ts b/packages/vendure-plugin-accept-blue/src/api/accept-blue-service.ts index 31623b0f..e6e55e04 100644 --- a/packages/vendure-plugin-accept-blue/src/api/accept-blue-service.ts +++ b/packages/vendure-plugin-accept-blue/src/api/accept-blue-service.ts @@ -20,16 +20,19 @@ import { TransactionalConnection, UserInputError, } from '@vendure/core'; +import { asError } from 'catch-unknown'; import crypto from 'node:crypto'; import { filter } from 'rxjs'; import { In } from 'typeorm'; -import { asError } from 'catch-unknown'; import * as util from 'util'; import { SubscriptionHelper } from '../'; import { AcceptBluePluginOptions } from '../accept-blue-plugin'; import { PLUGIN_INIT_OPTIONS, loggerCtx } from '../constants'; +import { AcceptBlueSubscriptionEvent } from '../events/accept-blue-subscription-event'; +import { AcceptBlueTransactionEvent } from '../events/accept-blue-transaction-event'; import { AcceptBlueChargeTransaction, + AcceptBlueCustomerInput, AcceptBlueEvent, AcceptBluePaymentMethod, AcceptBlueRecurringSchedule, @@ -54,8 +57,6 @@ import { AcceptBlueTransaction, UpdateAcceptBlueSubscriptionInput, } from './generated/graphql'; -import { AcceptBlueTransactionEvent } from '../events/accept-blue-transaction-event'; -import { AcceptBlueSubscriptionEvent } from '../events/accept-blue-subscription-event'; @Injectable() export class AcceptBlueService implements OnApplicationBootstrap { @@ -167,8 +168,9 @@ export class AcceptBlueService implements OnApplicationBootstrap { `We can only handle Accept Blue payments for logged in users, because we need to save the payment methods on Accept Blue customers` ); } - const acceptBlueCustomer = await client.getOrCreateCustomer( - order.customer.emailAddress + const acceptBlueCustomer = await client.upsertCustomer( + order.customer.emailAddress, + this.mapToAcceptBlueCustomerInput(order, order.customer) ); await this.customerService.update(ctx, { id: order.customer?.id, @@ -633,6 +635,49 @@ export class AcceptBlueService implements OnApplicationBootstrap { return orderLineIds as ID[]; } + /** + * Map a Vendure customer to an Accept Blue customer. + * Uses the order's shipping and billing address as customer address + */ + mapToAcceptBlueCustomerInput( + order: Order, + customer: Customer + ): AcceptBlueCustomerInput { + const shippingName = order.shippingAddress?.fullName?.split(' '); + const shippingAddress: AcceptBlueCustomerInput['shipping_info'] = { + first_name: shippingName?.[0] ?? customer.firstName, + last_name: shippingName?.[1] ?? customer.lastName, + street: order.shippingAddress?.streetLine1, + street2: order.shippingAddress?.streetLine2, + zip: order.shippingAddress?.postalCode, + state: order.shippingAddress?.province, + phone: order.shippingAddress?.phoneNumber, + city: order.shippingAddress?.city, + country: order.shippingAddress?.countryCode, + }; + const billingName = order.billingAddress?.fullName?.split(' '); + const billingAddress: AcceptBlueCustomerInput['billing_info'] = { + first_name: billingName?.[0] ?? customer.firstName, + last_name: billingName?.[1] ?? customer.lastName, + street: order.billingAddress?.streetLine1, + street2: order.billingAddress?.streetLine2, + zip: order.billingAddress?.postalCode, + state: order.billingAddress?.province, + phone: order.billingAddress?.phoneNumber, + city: order.billingAddress?.city, + country: order.billingAddress?.countryCode, + }; + return { + first_name: customer.firstName, + last_name: customer.lastName, + identifier: customer.emailAddress, + email: customer.emailAddress, + shipping_info: shippingAddress, + billing_info: billingAddress, + phone: customer.phoneNumber, + }; + } + /** * Map a subscription from Accept Blue to the GraphQL Subscription type */ diff --git a/packages/vendure-plugin-accept-blue/src/types.ts b/packages/vendure-plugin-accept-blue/src/types.ts index a9a32ba4..8484298c 100644 --- a/packages/vendure-plugin-accept-blue/src/types.ts +++ b/packages/vendure-plugin-accept-blue/src/types.ts @@ -34,7 +34,7 @@ export interface AcceptBlueAddress { city: string; zip: string; country: string; - phone: string; + phone?: string; } /** +++++ Transactions +++++ */ @@ -113,8 +113,8 @@ export interface AcceptBlueCustomerInput { website?: string; phone?: string; alternate_phone?: string; - billing_info?: AcceptBlueAddress; - shipping_info?: AcceptBlueAddress; + billing_info?: Partial; + shipping_info?: Partial; active?: boolean; } diff --git a/packages/vendure-plugin-accept-blue/test/accept-blue.spec.ts b/packages/vendure-plugin-accept-blue/test/accept-blue.spec.ts index a297e7c8..ca5b3d6e 100644 --- a/packages/vendure-plugin-accept-blue/test/accept-blue.spec.ts +++ b/packages/vendure-plugin-accept-blue/test/accept-blue.spec.ts @@ -67,6 +67,7 @@ import { MutationUpdateAcceptBlueSubscriptionArgs, UpdateAcceptBlueSubscriptionInput, } from '../src/api/generated/graphql'; +import { SetShippingAddress } from '../../test/src/generated/shop-graphql'; let server: TestServer; let adminClient: SimpleGraphQLClient; @@ -224,9 +225,10 @@ describe('Shop API', () => { }); }); -describe('Payment with Credit Card Payment Method', () => { - let createdSubscriptionIds: number[] = []; - it('Adds item to order', async () => { +describe('Payment with Saved Payment Method', () => { + let subscriptionIds: number[] = []; + + it('Adds item to order and set shipping address', async () => { await shopClient.asAnonymousUser(); await shopClient.asUserWithCredentials( 'hayden.zieme12@hotmail.com', @@ -235,17 +237,27 @@ describe('Payment with Credit Card Payment Method', () => { const { addItemToOrder: order } = await shopClient.query( ADD_ITEM_TO_ORDER, { - productVariantId: '1', + productVariantId: '3', quantity: 1, } ); + await shopClient.query(SetShippingAddress, { + input: { + fullName: 'Hayden Shipping Name', + streetLine1: 'Hayden Shipping Street 1', + streetLine2: 'Hayden Shipping Street 2', + city: 'City of Hayden', + postalCode: '1234 XX', + countryCode: 'US', + }, + }); // has subscription on orderline - expect(order.lines[0].acceptBlueSubscriptions?.[0]?.variantId).toBe('T_1'); + expect(order.lines[0].acceptBlueSubscriptions?.[0]?.variantId).toBe('T_3'); }); + let patchCustomerRequest: any = {}; + it('Adds payment to order', async () => { - //we need to use nock here for the following reasons, - //getOrCreateCustomer const queryParams = { active: true, customer_number: haydenZiemeCustomerDetails.customer_number, @@ -255,57 +267,83 @@ describe('Payment with Credit Card Payment Method', () => { .get(`/customers`) .query(queryParams) .reply(200, [haydenZiemeCustomerDetails]); - //getAllPaymentMethods + // patch customer details nockInstance - .persist() - .get( - `/customers/${haydenZiemeCustomerDetails.id}/payment-methods?limit=100` - ) - .reply(200, haydenSavedPaymentMethods); - //createRecurringSchedule + .patch(`/customers/${haydenZiemeCustomerDetails.id}`, (body) => { + patchCustomerRequest = body; + return true; + }) + .reply(200, [haydenZiemeCustomerDetails]); + // createRecurringSchedule + const recurringRequests: any[] = []; nockInstance .persist() - .post(`/customers/${haydenZiemeCustomerDetails.id}/recurring-schedules`) - .reply(201, createMockRecurringScheduleResult()); + .post( + `/customers/${haydenZiemeCustomerDetails.id}/recurring-schedules`, + (body) => { + recurringRequests.push(body); + return true; + } + ) + .reply(201, createMockRecurringScheduleResult(6014)); //createCharge nockInstance .persist() .post(`/transactions/charge`) - .reply(201, creditCardChargeResult); + .reply(201, checkChargeResult); await shopClient.query(SET_SHIPPING_METHOD, { id: [1], }); await shopClient.query(TRANSITION_ORDER_TO, { state: 'ArrangingPayment', }); - const metadata: NoncePaymentMethodInput = { - source: testingNonceToken.source, - expiry_year: testingNonceToken.expiry_year, - expiry_month: testingNonceToken.expiry_month, - last4: testingNonceToken.last4, - }; + const testPaymentMethod = + haydenSavedPaymentMethods[haydenSavedPaymentMethods.length - 1]; const { addPaymentToOrder: order } = await shopClient.query( ADD_PAYMENT_TO_ORDER, { input: { method: acceptBluePaymentMethod.code, - metadata, + metadata: { paymentMethodId: testPaymentMethod.id }, }, } ); - createdSubscriptionIds = order.lines + placedOrder = order; + subscriptionIds = order.lines .map((l: any) => l.customFields.acceptBlueSubscriptionIds) .flat(); expect(order.state).toBe('PaymentSettled'); + expect(recurringRequests.length).toBe(1); + expect(recurringRequests[0].amount).toBe(9); + }); + + it('Updated customer at Accept Blue', async () => { + expect(patchCustomerRequest).toEqual({ + first_name: 'Hayden', + last_name: 'Zieme', + identifier: 'hayden.zieme12@hotmail.com', + email: 'hayden.zieme12@hotmail.com', + shipping_info: { + first_name: 'Hayden', + last_name: 'Shipping', + street: 'Hayden Shipping Street 1', + street2: 'Hayden Shipping Street 2', + zip: '1234 XX', + city: 'City of Hayden', + country: 'US', + }, + billing_info: { first_name: 'Hayden', last_name: 'Zieme' }, + phone: '029 1203 1336', + }); }); it('Created subscriptions at Accept Blue', async () => { - expect(createdSubscriptionIds.length).toBeGreaterThan(0); + expect(subscriptionIds.length).toBeGreaterThan(0); }); }); -describe('Payment with Check Payment Method', () => { - let checkSubscriptionIds: number[] = []; +describe('Payment with Credit Card Payment Method', () => { + let createdSubscriptionIds: number[] = []; it('Adds item to order', async () => { await shopClient.asAnonymousUser(); await shopClient.asUserWithCredentials( @@ -315,15 +353,17 @@ describe('Payment with Check Payment Method', () => { const { addItemToOrder: order } = await shopClient.query( ADD_ITEM_TO_ORDER, { - productVariantId: '3', + productVariantId: '1', quantity: 1, } ); // has subscription on orderline - expect(order.lines[0].acceptBlueSubscriptions?.[0]?.variantId).toBe('T_3'); + expect(order.lines[0].acceptBlueSubscriptions?.[0]?.variantId).toBe('T_1'); }); it('Adds payment to order', async () => { + //we need to use nock here for the following reasons, + //getOrCreateCustomer const queryParams = { active: true, customer_number: haydenZiemeCustomerDetails.customer_number, @@ -349,21 +389,18 @@ describe('Payment with Check Payment Method', () => { nockInstance .persist() .post(`/transactions/charge`) - .reply(201, checkChargeResult); + .reply(201, creditCardChargeResult); await shopClient.query(SET_SHIPPING_METHOD, { id: [1], }); await shopClient.query(TRANSITION_ORDER_TO, { state: 'ArrangingPayment', }); - const testCheck = - haydenSavedPaymentMethods[haydenSavedPaymentMethods.length - 1]; - const metadata: CheckPaymentMethodInput = { - name: testCheck.name!, - routing_number: testCheck.routing_number!, - account_number: testCheck.account_number!, - account_type: testCheck.account_type! as AccountType, - sec_code: testCheck.sec_code! as SecCode, + const metadata: NoncePaymentMethodInput = { + source: testingNonceToken.source, + expiry_year: testingNonceToken.expiry_year, + expiry_month: testingNonceToken.expiry_month, + last4: testingNonceToken.last4, }; const { addPaymentToOrder: order } = await shopClient.query( ADD_PAYMENT_TO_ORDER, @@ -374,20 +411,19 @@ describe('Payment with Check Payment Method', () => { }, } ); - checkSubscriptionIds = order.lines + createdSubscriptionIds = order.lines .map((l: any) => l.customFields.acceptBlueSubscriptionIds) .flat(); expect(order.state).toBe('PaymentSettled'); }); it('Created subscriptions at Accept Blue', async () => { - expect(checkSubscriptionIds.length).toBeGreaterThan(0); + expect(createdSubscriptionIds.length).toBeGreaterThan(0); }); }); -describe('Payment with Saved Payment Method', () => { - let subscriptionIds: number[] = []; - +describe('Payment with Check Payment Method', () => { + let checkSubscriptionIds: number[] = []; it('Adds item to order', async () => { await shopClient.asAnonymousUser(); await shopClient.asUserWithCredentials( @@ -410,23 +446,23 @@ describe('Payment with Saved Payment Method', () => { active: true, customer_number: haydenZiemeCustomerDetails.customer_number, }; - const recurringRequests: any[] = []; nockInstance .persist() .get(`/customers`) .query(queryParams) .reply(200, [haydenZiemeCustomerDetails]); - //createRecurringSchedule + //getAllPaymentMethods nockInstance .persist() - .post( - `/customers/${haydenZiemeCustomerDetails.id}/recurring-schedules`, - (body) => { - recurringRequests.push(body); - return true; - } + .get( + `/customers/${haydenZiemeCustomerDetails.id}/payment-methods?limit=100` ) - .reply(201, createMockRecurringScheduleResult(6014)); + .reply(200, haydenSavedPaymentMethods); + //createRecurringSchedule + nockInstance + .persist() + .post(`/customers/${haydenZiemeCustomerDetails.id}/recurring-schedules`) + .reply(201, createMockRecurringScheduleResult()); //createCharge nockInstance .persist() @@ -438,28 +474,32 @@ describe('Payment with Saved Payment Method', () => { await shopClient.query(TRANSITION_ORDER_TO, { state: 'ArrangingPayment', }); - const testPaymentMethod = + const testCheck = haydenSavedPaymentMethods[haydenSavedPaymentMethods.length - 1]; + const metadata: CheckPaymentMethodInput = { + name: testCheck.name!, + routing_number: testCheck.routing_number!, + account_number: testCheck.account_number!, + account_type: testCheck.account_type! as AccountType, + sec_code: testCheck.sec_code! as SecCode, + }; const { addPaymentToOrder: order } = await shopClient.query( ADD_PAYMENT_TO_ORDER, { input: { method: acceptBluePaymentMethod.code, - metadata: { paymentMethodId: testPaymentMethod.id }, + metadata, }, } ); - placedOrder = order; - subscriptionIds = order.lines + checkSubscriptionIds = order.lines .map((l: any) => l.customFields.acceptBlueSubscriptionIds) .flat(); expect(order.state).toBe('PaymentSettled'); - expect(recurringRequests.length).toBe(1); - expect(recurringRequests[0].amount).toBe(9); }); it('Created subscriptions at Accept Blue', async () => { - expect(subscriptionIds.length).toBeGreaterThan(0); + expect(checkSubscriptionIds.length).toBeGreaterThan(0); }); }); diff --git a/packages/vendure-plugin-accept-blue/test/dev-server.ts b/packages/vendure-plugin-accept-blue/test/dev-server.ts index 49e7ccda..59d647c9 100644 --- a/packages/vendure-plugin-accept-blue/test/dev-server.ts +++ b/packages/vendure-plugin-accept-blue/test/dev-server.ts @@ -30,6 +30,7 @@ import { import { NoncePaymentMethodInput } from '../src/types'; import { add } from 'date-fns'; import { TestSubscriptionStrategy } from './helpers/test-subscription-strategy'; +import { SetShippingAddress } from '../../test/src/generated/shop-graphql'; /** * Ensure you have a .env in the plugin root directory with the variable ACCEPT_BLUE_TOKENIZATION_SOURCE_KEY=pk-abc123 @@ -131,6 +132,16 @@ import { TestSubscriptionStrategy } from './helpers/test-subscription-strategy'; quantity: 1, }); console.log(`Added item`); + await shopClient.query(SetShippingAddress, { + input: { + fullName: 'Hayden Shipping Name', + streetLine1: 'Hayden Shipping Street 1', + streetLine2: 'Hayden Shipping Street 2', + city: 'City of Hayden', + postalCode: '1234 XX', + countryCode: 'US', + }, + }); await shopClient.query(SET_SHIPPING_METHOD, { id: [1], }); diff --git a/packages/vendure-plugin-accept-blue/test/helpers/test-subscription-strategy.ts b/packages/vendure-plugin-accept-blue/test/helpers/test-subscription-strategy.ts index ac117813..9e705011 100644 --- a/packages/vendure-plugin-accept-blue/test/helpers/test-subscription-strategy.ts +++ b/packages/vendure-plugin-accept-blue/test/helpers/test-subscription-strategy.ts @@ -33,7 +33,6 @@ export class TestSubscriptionStrategy implements SubscriptionStrategy { private getSubscriptionForVariant( productVariant: ProductVariant ): Subscription { - console.log('TRIGGERERED', productVariant.listPrice); return { name: `Test Subscription ${productVariant.name}`, priceIncludesTax: true,