From 3894be65fb5c78c778aa06f148e46aa587bbc0bf Mon Sep 17 00:00:00 2001 From: Raul Ranete Date: Wed, 9 Jul 2025 15:22:46 +0300 Subject: [PATCH 1/8] feat(backend): add card information to outgoing payment graph ql --- .../generated/graphql.ts | 41 +++- ...0748_create_outgoing_payment_card_table.js | 28 +++ .../src/graphql/generated/graphql.schema.json | 187 ++++++++++++++++++ .../backend/src/graphql/generated/graphql.ts | 41 +++- .../src/graphql/resolvers/outgoing_payment.ts | 19 +- packages/backend/src/graphql/schema.graphql | 23 ++- .../payment/outgoing/card/model.ts | 20 ++ .../open_payments/payment/outgoing/model.ts | 27 ++- .../open_payments/payment/outgoing/service.ts | 27 +++ packages/frontend/app/generated/graphql.ts | 41 +++- .../src/generated/graphql.ts | 41 +++- test/test-lib/src/generated/graphql.ts | 41 +++- 12 files changed, 520 insertions(+), 16 deletions(-) create mode 100644 packages/backend/migrations/20250708070748_create_outgoing_payment_card_table.js create mode 100644 packages/backend/src/open_payments/payment/outgoing/card/model.ts diff --git a/localenv/mock-account-servicing-entity/generated/graphql.ts b/localenv/mock-account-servicing-entity/generated/graphql.ts index 35e0959e56..4706d84425 100644 --- a/localenv/mock-account-servicing-entity/generated/graphql.ts +++ b/localenv/mock-account-servicing-entity/generated/graphql.ts @@ -191,6 +191,13 @@ export type CancelOutgoingPaymentInput = { reason?: InputMaybe; }; +export type CardDetailsInput = { + /** Expire date */ + expiry: Scalars['String']['input']; + /** Signature */ + signature: Scalars['String']['input']; +}; + export type CreateAssetInput = { /** Should be an ISO 4217 currency code whenever possible, e.g. `USD`. For more information, refer to [assets](https://rafiki.dev/overview/concepts/accounting/#assets). */ code: Scalars['String']['input']; @@ -278,6 +285,8 @@ export type CreateOutgoingPaymentFromIncomingPaymentInput = { }; export type CreateOutgoingPaymentInput = { + /** Used for the card service to provide the card expiry and signature */ + cardDetails?: InputMaybe; /** Unique key to ensure duplicate or retried requests are processed only once. For more information, refer to [idempotency](https://rafiki.dev/apis/graphql/admin-api-overview/#idempotency). */ idempotencyKey?: InputMaybe; /** Additional metadata associated with the outgoing payment. */ @@ -1006,6 +1015,8 @@ export type MutationWithdrawEventLiquidityArgs = { export type OutgoingPayment = BasePayment & Model & { __typename?: 'OutgoingPayment'; + /** Used for the card service to provide the card expiry and signature */ + cardDetails?: Maybe; /** Information about the wallet address of the Open Payments client that created the outgoing payment. */ client?: Maybe; /** The date and time that the outgoing payment was created. */ @@ -1040,6 +1051,16 @@ export type OutgoingPayment = BasePayment & Model & { walletAddressId: Scalars['ID']['output']; }; +export type OutgoingPaymentCardDetails = Model & { + __typename?: 'OutgoingPaymentCardDetails'; + createdAt: Scalars['String']['output']; + expiry: Scalars['String']['output']; + id: Scalars['ID']['output']; + outgoingPaymentId: Scalars['ID']['output']; + signature: Scalars['String']['output']; + updatedAt: Scalars['String']['output']; +}; + export type OutgoingPaymentConnection = { __typename?: 'OutgoingPaymentConnection'; /** A list of outgoing payment edges, containing outgoing payment nodes and cursors for pagination. */ @@ -1898,7 +1919,7 @@ export type DirectiveResolverFn> = { BasePayment: ( Partial ) | ( Partial ) | ( Partial ); - Model: ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ); + Model: ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ); }; /** Mapping between all available schema types and the resolvers types */ @@ -1921,6 +1942,7 @@ export type ResolversTypes = { CancelIncomingPaymentInput: ResolverTypeWrapper>; CancelIncomingPaymentResponse: ResolverTypeWrapper>; CancelOutgoingPaymentInput: ResolverTypeWrapper>; + CardDetailsInput: ResolverTypeWrapper>; CreateAssetInput: ResolverTypeWrapper>; CreateAssetLiquidityWithdrawalInput: ResolverTypeWrapper>; CreateIncomingPaymentInput: ResolverTypeWrapper>; @@ -1982,6 +2004,7 @@ export type ResolversTypes = { Model: ResolverTypeWrapper['Model']>; Mutation: ResolverTypeWrapper<{}>; OutgoingPayment: ResolverTypeWrapper>; + OutgoingPaymentCardDetails: ResolverTypeWrapper>; OutgoingPaymentConnection: ResolverTypeWrapper>; OutgoingPaymentEdge: ResolverTypeWrapper>; OutgoingPaymentFilter: ResolverTypeWrapper>; @@ -2065,6 +2088,7 @@ export type ResolversParentTypes = { CancelIncomingPaymentInput: Partial; CancelIncomingPaymentResponse: Partial; CancelOutgoingPaymentInput: Partial; + CardDetailsInput: Partial; CreateAssetInput: Partial; CreateAssetLiquidityWithdrawalInput: Partial; CreateIncomingPaymentInput: Partial; @@ -2121,6 +2145,7 @@ export type ResolversParentTypes = { Model: ResolversInterfaceTypes['Model']; Mutation: {}; OutgoingPayment: Partial; + OutgoingPaymentCardDetails: Partial; OutgoingPaymentConnection: Partial; OutgoingPaymentEdge: Partial; OutgoingPaymentFilter: Partial; @@ -2392,7 +2417,7 @@ export type LiquidityMutationResponseResolvers = { - __resolveType: TypeResolveFn<'AccountingTransfer' | 'Asset' | 'Fee' | 'IncomingPayment' | 'OutgoingPayment' | 'Payment' | 'Peer' | 'Tenant' | 'WalletAddress' | 'WalletAddressKey' | 'WebhookEvent', ParentType, ContextType>; + __resolveType: TypeResolveFn<'AccountingTransfer' | 'Asset' | 'Fee' | 'IncomingPayment' | 'OutgoingPayment' | 'OutgoingPaymentCardDetails' | 'Payment' | 'Peer' | 'Tenant' | 'WalletAddress' | 'WalletAddressKey' | 'WebhookEvent', ParentType, ContextType>; createdAt?: Resolver; id?: Resolver; }; @@ -2439,6 +2464,7 @@ export type MutationResolvers = { + cardDetails?: Resolver, ParentType, ContextType>; client?: Resolver, ParentType, ContextType>; createdAt?: Resolver; debitAmount?: Resolver; @@ -2458,6 +2484,16 @@ export type OutgoingPaymentResolvers; }; +export type OutgoingPaymentCardDetailsResolvers = { + createdAt?: Resolver; + expiry?: Resolver; + id?: Resolver; + outgoingPaymentId?: Resolver; + signature?: Resolver; + updatedAt?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type OutgoingPaymentConnectionResolvers = { edges?: Resolver, ParentType, ContextType>; pageInfo?: Resolver; @@ -2792,6 +2828,7 @@ export type Resolvers = { Model?: ModelResolvers; Mutation?: MutationResolvers; OutgoingPayment?: OutgoingPaymentResolvers; + OutgoingPaymentCardDetails?: OutgoingPaymentCardDetailsResolvers; OutgoingPaymentConnection?: OutgoingPaymentConnectionResolvers; OutgoingPaymentEdge?: OutgoingPaymentEdgeResolvers; OutgoingPaymentResponse?: OutgoingPaymentResponseResolvers; diff --git a/packages/backend/migrations/20250708070748_create_outgoing_payment_card_table.js b/packages/backend/migrations/20250708070748_create_outgoing_payment_card_table.js new file mode 100644 index 0000000000..8053bb92a8 --- /dev/null +++ b/packages/backend/migrations/20250708070748_create_outgoing_payment_card_table.js @@ -0,0 +1,28 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function(knex) { + return knex.schema.createTable('outgoingPaymentCardDetails', function(table) { + table.uuid('id').notNullable().primary() + table.string('signature').notNullable(); + table.timestamp('expiry').notNullable(); + table.uuid('outgoingPaymentId').notNullable(); + + table.foreign('outgoingPaymentId') + .references('id') + .inTable('outgoingPayments') + .onDelete('CASCADE'); + + table.timestamp('createdAt').defaultTo(knex.fn.now()) + table.timestamp('updatedAt').defaultTo(knex.fn.now()) + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function(knex) { + return knex.schema.dropTableIfExists('outgoingPaymentCardDetails'); +}; diff --git a/packages/backend/src/graphql/generated/graphql.schema.json b/packages/backend/src/graphql/generated/graphql.schema.json index b1a2237a96..462fd237c1 100644 --- a/packages/backend/src/graphql/generated/graphql.schema.json +++ b/packages/backend/src/graphql/generated/graphql.schema.json @@ -1092,6 +1092,50 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "INPUT_OBJECT", + "name": "CardDetailsInput", + "description": null, + "isOneOf": false, + "fields": null, + "inputFields": [ + { + "name": "expiry", + "description": "Expire date", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "signature", + "description": "Signature", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, { "kind": "INPUT_OBJECT", "name": "CreateAssetInput", @@ -1631,6 +1675,18 @@ "isOneOf": false, "fields": null, "inputFields": [ + { + "name": "cardDetails", + "description": "Used for the card service to provide the card expiry and signature", + "type": { + "kind": "INPUT_OBJECT", + "name": "CardDetailsInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "idempotencyKey", "description": "Unique key to ensure duplicate or retried requests are processed only once. For more information, refer to [idempotency](https://rafiki.dev/apis/graphql/admin-api-overview/#idempotency).", @@ -4313,6 +4369,11 @@ "name": "OutgoingPayment", "ofType": null }, + { + "kind": "OBJECT", + "name": "OutgoingPaymentCardDetails", + "ofType": null + }, { "kind": "OBJECT", "name": "Payment", @@ -5557,6 +5618,18 @@ "description": null, "isOneOf": null, "fields": [ + { + "name": "cardDetails", + "description": "Used for the card service to provide the card expiry and signature", + "args": [], + "type": { + "kind": "OBJECT", + "name": "OutgoingPaymentCardDetails", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "client", "description": "Information about the wallet address of the Open Payments client that created the outgoing payment.", @@ -5802,6 +5875,120 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "OutgoingPaymentCardDetails", + "description": null, + "isOneOf": null, + "fields": [ + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "expiry", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "outgoingPaymentId", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "signature", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + { + "kind": "INTERFACE", + "name": "Model", + "ofType": null + } + ], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "OutgoingPaymentConnection", diff --git a/packages/backend/src/graphql/generated/graphql.ts b/packages/backend/src/graphql/generated/graphql.ts index 35e0959e56..4706d84425 100644 --- a/packages/backend/src/graphql/generated/graphql.ts +++ b/packages/backend/src/graphql/generated/graphql.ts @@ -191,6 +191,13 @@ export type CancelOutgoingPaymentInput = { reason?: InputMaybe; }; +export type CardDetailsInput = { + /** Expire date */ + expiry: Scalars['String']['input']; + /** Signature */ + signature: Scalars['String']['input']; +}; + export type CreateAssetInput = { /** Should be an ISO 4217 currency code whenever possible, e.g. `USD`. For more information, refer to [assets](https://rafiki.dev/overview/concepts/accounting/#assets). */ code: Scalars['String']['input']; @@ -278,6 +285,8 @@ export type CreateOutgoingPaymentFromIncomingPaymentInput = { }; export type CreateOutgoingPaymentInput = { + /** Used for the card service to provide the card expiry and signature */ + cardDetails?: InputMaybe; /** Unique key to ensure duplicate or retried requests are processed only once. For more information, refer to [idempotency](https://rafiki.dev/apis/graphql/admin-api-overview/#idempotency). */ idempotencyKey?: InputMaybe; /** Additional metadata associated with the outgoing payment. */ @@ -1006,6 +1015,8 @@ export type MutationWithdrawEventLiquidityArgs = { export type OutgoingPayment = BasePayment & Model & { __typename?: 'OutgoingPayment'; + /** Used for the card service to provide the card expiry and signature */ + cardDetails?: Maybe; /** Information about the wallet address of the Open Payments client that created the outgoing payment. */ client?: Maybe; /** The date and time that the outgoing payment was created. */ @@ -1040,6 +1051,16 @@ export type OutgoingPayment = BasePayment & Model & { walletAddressId: Scalars['ID']['output']; }; +export type OutgoingPaymentCardDetails = Model & { + __typename?: 'OutgoingPaymentCardDetails'; + createdAt: Scalars['String']['output']; + expiry: Scalars['String']['output']; + id: Scalars['ID']['output']; + outgoingPaymentId: Scalars['ID']['output']; + signature: Scalars['String']['output']; + updatedAt: Scalars['String']['output']; +}; + export type OutgoingPaymentConnection = { __typename?: 'OutgoingPaymentConnection'; /** A list of outgoing payment edges, containing outgoing payment nodes and cursors for pagination. */ @@ -1898,7 +1919,7 @@ export type DirectiveResolverFn> = { BasePayment: ( Partial ) | ( Partial ) | ( Partial ); - Model: ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ); + Model: ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ); }; /** Mapping between all available schema types and the resolvers types */ @@ -1921,6 +1942,7 @@ export type ResolversTypes = { CancelIncomingPaymentInput: ResolverTypeWrapper>; CancelIncomingPaymentResponse: ResolverTypeWrapper>; CancelOutgoingPaymentInput: ResolverTypeWrapper>; + CardDetailsInput: ResolverTypeWrapper>; CreateAssetInput: ResolverTypeWrapper>; CreateAssetLiquidityWithdrawalInput: ResolverTypeWrapper>; CreateIncomingPaymentInput: ResolverTypeWrapper>; @@ -1982,6 +2004,7 @@ export type ResolversTypes = { Model: ResolverTypeWrapper['Model']>; Mutation: ResolverTypeWrapper<{}>; OutgoingPayment: ResolverTypeWrapper>; + OutgoingPaymentCardDetails: ResolverTypeWrapper>; OutgoingPaymentConnection: ResolverTypeWrapper>; OutgoingPaymentEdge: ResolverTypeWrapper>; OutgoingPaymentFilter: ResolverTypeWrapper>; @@ -2065,6 +2088,7 @@ export type ResolversParentTypes = { CancelIncomingPaymentInput: Partial; CancelIncomingPaymentResponse: Partial; CancelOutgoingPaymentInput: Partial; + CardDetailsInput: Partial; CreateAssetInput: Partial; CreateAssetLiquidityWithdrawalInput: Partial; CreateIncomingPaymentInput: Partial; @@ -2121,6 +2145,7 @@ export type ResolversParentTypes = { Model: ResolversInterfaceTypes['Model']; Mutation: {}; OutgoingPayment: Partial; + OutgoingPaymentCardDetails: Partial; OutgoingPaymentConnection: Partial; OutgoingPaymentEdge: Partial; OutgoingPaymentFilter: Partial; @@ -2392,7 +2417,7 @@ export type LiquidityMutationResponseResolvers = { - __resolveType: TypeResolveFn<'AccountingTransfer' | 'Asset' | 'Fee' | 'IncomingPayment' | 'OutgoingPayment' | 'Payment' | 'Peer' | 'Tenant' | 'WalletAddress' | 'WalletAddressKey' | 'WebhookEvent', ParentType, ContextType>; + __resolveType: TypeResolveFn<'AccountingTransfer' | 'Asset' | 'Fee' | 'IncomingPayment' | 'OutgoingPayment' | 'OutgoingPaymentCardDetails' | 'Payment' | 'Peer' | 'Tenant' | 'WalletAddress' | 'WalletAddressKey' | 'WebhookEvent', ParentType, ContextType>; createdAt?: Resolver; id?: Resolver; }; @@ -2439,6 +2464,7 @@ export type MutationResolvers = { + cardDetails?: Resolver, ParentType, ContextType>; client?: Resolver, ParentType, ContextType>; createdAt?: Resolver; debitAmount?: Resolver; @@ -2458,6 +2484,16 @@ export type OutgoingPaymentResolvers; }; +export type OutgoingPaymentCardDetailsResolvers = { + createdAt?: Resolver; + expiry?: Resolver; + id?: Resolver; + outgoingPaymentId?: Resolver; + signature?: Resolver; + updatedAt?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type OutgoingPaymentConnectionResolvers = { edges?: Resolver, ParentType, ContextType>; pageInfo?: Resolver; @@ -2792,6 +2828,7 @@ export type Resolvers = { Model?: ModelResolvers; Mutation?: MutationResolvers; OutgoingPayment?: OutgoingPaymentResolvers; + OutgoingPaymentCardDetails?: OutgoingPaymentCardDetailsResolvers; OutgoingPaymentConnection?: OutgoingPaymentConnectionResolvers; OutgoingPaymentEdge?: OutgoingPaymentEdgeResolvers; OutgoingPaymentResponse?: OutgoingPaymentResponseResolvers; diff --git a/packages/backend/src/graphql/resolvers/outgoing_payment.ts b/packages/backend/src/graphql/resolvers/outgoing_payment.ts index 7eb85b86ee..3a49fb6059 100644 --- a/packages/backend/src/graphql/resolvers/outgoing_payment.ts +++ b/packages/backend/src/graphql/resolvers/outgoing_payment.ts @@ -4,7 +4,8 @@ import { OutgoingPayment as SchemaOutgoingPayment, WalletAddressResolvers, QueryResolvers, - ResolversTypes + ResolversTypes, + OutgoingPaymentCardDetails } from '../generated/graphql' import { isOutgoingPaymentError, @@ -248,6 +249,20 @@ export function paymentToGraphql( createdAt: new Date(+payment.createdAt).toISOString(), quote: quoteToGraphql(payment.quote), grantId: payment.grantId, - tenantId: payment.tenantId + tenantId: payment.tenantId, + cardDetails: cardToGraphql(payment) } } +function cardToGraphql(payment: OutgoingPayment): OutgoingPaymentCardDetails | undefined { + if (!payment.cardDetails) + return undefined + return { + id: payment.cardDetails.id, + outgoingPaymentId: payment.cardDetails.outgoingPaymentId, + expiry: new Date(+payment.cardDetails.expiry).toISOString(), + signature: payment.cardDetails.signature, + updatedAt: new Date(+payment.cardDetails.updatedAt).toISOString(), + createdAt: new Date(+payment.cardDetails.createdAt).toISOString() + } +} + diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index 4750a2b300..b14ce3a23e 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -1051,7 +1051,19 @@ type OutgoingPayment implements BasePayment & Model { "Unique identifier of the grant under which the outgoing payment was created." grantId: String "Tenant ID of the outgoing payment." - tenantId: String + tenantId: String, + "Used for the card service to provide the card expiry and signature" + cardDetails: OutgoingPaymentCardDetails +} + +type OutgoingPaymentCardDetails implements Model { + id: ID! + outgoingPaymentId: ID! + signature: String! + expiry: String! + createdAt: String! + updatedAt: String! + } enum OutgoingPaymentState { @@ -1229,6 +1241,15 @@ input CreateOutgoingPaymentInput { metadata: JSONObject "Unique key to ensure duplicate or retried requests are processed only once. For more information, refer to [idempotency](https://rafiki.dev/apis/graphql/admin-api-overview/#idempotency)." idempotencyKey: String + "Used for the card service to provide the card expiry and signature" + cardDetails: CardDetailsInput +} + +input CardDetailsInput { + "Signature" + signature: String! + "Expire date" + expiry: String! } input CancelOutgoingPaymentInput { diff --git a/packages/backend/src/open_payments/payment/outgoing/card/model.ts b/packages/backend/src/open_payments/payment/outgoing/card/model.ts new file mode 100644 index 0000000000..5475866e6a --- /dev/null +++ b/packages/backend/src/open_payments/payment/outgoing/card/model.ts @@ -0,0 +1,20 @@ +import { Pojo } from 'objection' +import { BaseModel } from '../../../../shared/baseModel' + +export class OutgoingPaymentsCardDetails extends BaseModel { + public static get tableName(): string { + return 'outgoingPaymentCardDetails' + } + + public expiry!: Date + public readonly outgoingPaymentId!: string + public signature!: string + + $formatJson(json: Pojo): Pojo { + json = super.$formatJson(json) + return { + ...json, + expiry: json.expiry.toISOString() + } + } +} diff --git a/packages/backend/src/open_payments/payment/outgoing/model.ts b/packages/backend/src/open_payments/payment/outgoing/model.ts index 607482d32e..1c0fd7e1ef 100644 --- a/packages/backend/src/open_payments/payment/outgoing/model.ts +++ b/packages/backend/src/open_payments/payment/outgoing/model.ts @@ -1,4 +1,4 @@ -import { Model, ModelOptions, QueryContext } from 'objection' +import { Model, ModelOptions, Pojo, QueryContext } from 'objection' import { DbErrors } from 'objection-db-errors' import { LiquidityAccount } from '../../../accounting/service' @@ -16,6 +16,7 @@ import { OutgoingPaymentWithSpentAmounts } from '@interledger/open-payments' import { Tenant } from '../../../tenants/model' +import { OutgoingPaymentsCardDetails } from './card/model' export class OutgoingPaymentGrant extends DbErrors(Model) { public static get modelPaths(): string[] { @@ -101,6 +102,8 @@ export class OutgoingPayment public metadata?: Record + public cardDetails?: OutgoingPaymentsCardDetails; + public quote!: Quote public get assetId(): string { @@ -146,7 +149,16 @@ export class OutgoingPayment from: 'outgoingPayments.tenantId', to: 'tenants.id' } - } + }, + cardDetails: { + relation: Model.HasOneRelation, + modelClass: OutgoingPaymentsCardDetails, + join: { + from: 'outgoingPayments.id', + to: 'outgoingPaymentsCardDetails.outgoingPaymentId' + + } + }, } } @@ -316,4 +328,13 @@ export class OutgoingPaymentEvent extends WebhookEvent { throw new Error(OutgoingPaymentEventError.OutgoingPaymentIdRequired) } } -} + + $formatJson(json: Pojo): Pojo { + json = super.$formatJson(json) + return { + ...json, + cardDetails: new OutgoingPaymentsCardDetails().$formatJson(json.card), + } + } + } + diff --git a/packages/backend/src/open_payments/payment/outgoing/service.ts b/packages/backend/src/open_payments/payment/outgoing/service.ts index 1cc63bdcab..d47518439f 100644 --- a/packages/backend/src/open_payments/payment/outgoing/service.ts +++ b/packages/backend/src/open_payments/payment/outgoing/service.ts @@ -46,6 +46,7 @@ import { IAppConfig } from '../../../config/app' import { AssetService } from '../../../asset/service' import { Span, trace } from '@opentelemetry/api' import { FeeService } from '../../../fee/service' +import { OutgoingPaymentsCardDetails } from './card/model' export interface OutgoingPaymentService extends WalletAddressSubresourceService { @@ -201,6 +202,13 @@ export interface CreateFromIncomingPayment extends BaseOptions { debitAmount: Amount } +export interface CreateFromCardPayment extends CreateFromIncomingPayment { + cardDetails: { + expiry: string + signature: string + } +} + export type CancelOutgoingPaymentOptions = { id: string tenantId: string @@ -210,6 +218,13 @@ export type CancelOutgoingPaymentOptions = { export type CreateOutgoingPaymentOptions = | CreateFromQuote | CreateFromIncomingPayment + | CreateFromCardPayment + +export function isCreateFromCardPayment( + options: CreateOutgoingPaymentOptions +): options is CreateFromCardPayment { + return 'cardDetails' in options && 'expiry' in options.cardDetails && 'signature' in options.cardDetails +} export function isCreateFromIncomingPayment( options: CreateOutgoingPaymentOptions @@ -398,6 +413,18 @@ async function createOutgoingPayment( state: OutgoingPaymentState.Funding, grantId }) + + if (isCreateFromCardPayment(options)) { + const card = await OutgoingPaymentsCardDetails.query( + trx + ).insertAndFetch({ + outgoingPaymentId: payment.id, + expiry: new Date(options.cardDetails?.expiry), + signature: options.cardDetails?.signature + }) + payment.cardDetails = card + } + payment.walletAddress = walletAddress payment.quote = quote if (asset) payment.quote.asset = asset diff --git a/packages/frontend/app/generated/graphql.ts b/packages/frontend/app/generated/graphql.ts index 89a4198ca4..f9c08b716e 100644 --- a/packages/frontend/app/generated/graphql.ts +++ b/packages/frontend/app/generated/graphql.ts @@ -191,6 +191,13 @@ export type CancelOutgoingPaymentInput = { reason?: InputMaybe; }; +export type CardDetailsInput = { + /** Expire date */ + expiry: Scalars['String']['input']; + /** Signature */ + signature: Scalars['String']['input']; +}; + export type CreateAssetInput = { /** Should be an ISO 4217 currency code whenever possible, e.g. `USD`. For more information, refer to [assets](https://rafiki.dev/overview/concepts/accounting/#assets). */ code: Scalars['String']['input']; @@ -278,6 +285,8 @@ export type CreateOutgoingPaymentFromIncomingPaymentInput = { }; export type CreateOutgoingPaymentInput = { + /** Used for the card service to provide the card expiry and signature */ + cardDetails?: InputMaybe; /** Unique key to ensure duplicate or retried requests are processed only once. For more information, refer to [idempotency](https://rafiki.dev/apis/graphql/admin-api-overview/#idempotency). */ idempotencyKey?: InputMaybe; /** Additional metadata associated with the outgoing payment. */ @@ -1006,6 +1015,8 @@ export type MutationWithdrawEventLiquidityArgs = { export type OutgoingPayment = BasePayment & Model & { __typename?: 'OutgoingPayment'; + /** Used for the card service to provide the card expiry and signature */ + cardDetails?: Maybe; /** Information about the wallet address of the Open Payments client that created the outgoing payment. */ client?: Maybe; /** The date and time that the outgoing payment was created. */ @@ -1040,6 +1051,16 @@ export type OutgoingPayment = BasePayment & Model & { walletAddressId: Scalars['ID']['output']; }; +export type OutgoingPaymentCardDetails = Model & { + __typename?: 'OutgoingPaymentCardDetails'; + createdAt: Scalars['String']['output']; + expiry: Scalars['String']['output']; + id: Scalars['ID']['output']; + outgoingPaymentId: Scalars['ID']['output']; + signature: Scalars['String']['output']; + updatedAt: Scalars['String']['output']; +}; + export type OutgoingPaymentConnection = { __typename?: 'OutgoingPaymentConnection'; /** A list of outgoing payment edges, containing outgoing payment nodes and cursors for pagination. */ @@ -1898,7 +1919,7 @@ export type DirectiveResolverFn> = { BasePayment: ( Partial ) | ( Partial ) | ( Partial ); - Model: ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ); + Model: ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ); }; /** Mapping between all available schema types and the resolvers types */ @@ -1921,6 +1942,7 @@ export type ResolversTypes = { CancelIncomingPaymentInput: ResolverTypeWrapper>; CancelIncomingPaymentResponse: ResolverTypeWrapper>; CancelOutgoingPaymentInput: ResolverTypeWrapper>; + CardDetailsInput: ResolverTypeWrapper>; CreateAssetInput: ResolverTypeWrapper>; CreateAssetLiquidityWithdrawalInput: ResolverTypeWrapper>; CreateIncomingPaymentInput: ResolverTypeWrapper>; @@ -1982,6 +2004,7 @@ export type ResolversTypes = { Model: ResolverTypeWrapper['Model']>; Mutation: ResolverTypeWrapper<{}>; OutgoingPayment: ResolverTypeWrapper>; + OutgoingPaymentCardDetails: ResolverTypeWrapper>; OutgoingPaymentConnection: ResolverTypeWrapper>; OutgoingPaymentEdge: ResolverTypeWrapper>; OutgoingPaymentFilter: ResolverTypeWrapper>; @@ -2065,6 +2088,7 @@ export type ResolversParentTypes = { CancelIncomingPaymentInput: Partial; CancelIncomingPaymentResponse: Partial; CancelOutgoingPaymentInput: Partial; + CardDetailsInput: Partial; CreateAssetInput: Partial; CreateAssetLiquidityWithdrawalInput: Partial; CreateIncomingPaymentInput: Partial; @@ -2121,6 +2145,7 @@ export type ResolversParentTypes = { Model: ResolversInterfaceTypes['Model']; Mutation: {}; OutgoingPayment: Partial; + OutgoingPaymentCardDetails: Partial; OutgoingPaymentConnection: Partial; OutgoingPaymentEdge: Partial; OutgoingPaymentFilter: Partial; @@ -2392,7 +2417,7 @@ export type LiquidityMutationResponseResolvers = { - __resolveType: TypeResolveFn<'AccountingTransfer' | 'Asset' | 'Fee' | 'IncomingPayment' | 'OutgoingPayment' | 'Payment' | 'Peer' | 'Tenant' | 'WalletAddress' | 'WalletAddressKey' | 'WebhookEvent', ParentType, ContextType>; + __resolveType: TypeResolveFn<'AccountingTransfer' | 'Asset' | 'Fee' | 'IncomingPayment' | 'OutgoingPayment' | 'OutgoingPaymentCardDetails' | 'Payment' | 'Peer' | 'Tenant' | 'WalletAddress' | 'WalletAddressKey' | 'WebhookEvent', ParentType, ContextType>; createdAt?: Resolver; id?: Resolver; }; @@ -2439,6 +2464,7 @@ export type MutationResolvers = { + cardDetails?: Resolver, ParentType, ContextType>; client?: Resolver, ParentType, ContextType>; createdAt?: Resolver; debitAmount?: Resolver; @@ -2458,6 +2484,16 @@ export type OutgoingPaymentResolvers; }; +export type OutgoingPaymentCardDetailsResolvers = { + createdAt?: Resolver; + expiry?: Resolver; + id?: Resolver; + outgoingPaymentId?: Resolver; + signature?: Resolver; + updatedAt?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type OutgoingPaymentConnectionResolvers = { edges?: Resolver, ParentType, ContextType>; pageInfo?: Resolver; @@ -2792,6 +2828,7 @@ export type Resolvers = { Model?: ModelResolvers; Mutation?: MutationResolvers; OutgoingPayment?: OutgoingPaymentResolvers; + OutgoingPaymentCardDetails?: OutgoingPaymentCardDetailsResolvers; OutgoingPaymentConnection?: OutgoingPaymentConnectionResolvers; OutgoingPaymentEdge?: OutgoingPaymentEdgeResolvers; OutgoingPaymentResponse?: OutgoingPaymentResponseResolvers; diff --git a/packages/mock-account-service-lib/src/generated/graphql.ts b/packages/mock-account-service-lib/src/generated/graphql.ts index 35e0959e56..4706d84425 100644 --- a/packages/mock-account-service-lib/src/generated/graphql.ts +++ b/packages/mock-account-service-lib/src/generated/graphql.ts @@ -191,6 +191,13 @@ export type CancelOutgoingPaymentInput = { reason?: InputMaybe; }; +export type CardDetailsInput = { + /** Expire date */ + expiry: Scalars['String']['input']; + /** Signature */ + signature: Scalars['String']['input']; +}; + export type CreateAssetInput = { /** Should be an ISO 4217 currency code whenever possible, e.g. `USD`. For more information, refer to [assets](https://rafiki.dev/overview/concepts/accounting/#assets). */ code: Scalars['String']['input']; @@ -278,6 +285,8 @@ export type CreateOutgoingPaymentFromIncomingPaymentInput = { }; export type CreateOutgoingPaymentInput = { + /** Used for the card service to provide the card expiry and signature */ + cardDetails?: InputMaybe; /** Unique key to ensure duplicate or retried requests are processed only once. For more information, refer to [idempotency](https://rafiki.dev/apis/graphql/admin-api-overview/#idempotency). */ idempotencyKey?: InputMaybe; /** Additional metadata associated with the outgoing payment. */ @@ -1006,6 +1015,8 @@ export type MutationWithdrawEventLiquidityArgs = { export type OutgoingPayment = BasePayment & Model & { __typename?: 'OutgoingPayment'; + /** Used for the card service to provide the card expiry and signature */ + cardDetails?: Maybe; /** Information about the wallet address of the Open Payments client that created the outgoing payment. */ client?: Maybe; /** The date and time that the outgoing payment was created. */ @@ -1040,6 +1051,16 @@ export type OutgoingPayment = BasePayment & Model & { walletAddressId: Scalars['ID']['output']; }; +export type OutgoingPaymentCardDetails = Model & { + __typename?: 'OutgoingPaymentCardDetails'; + createdAt: Scalars['String']['output']; + expiry: Scalars['String']['output']; + id: Scalars['ID']['output']; + outgoingPaymentId: Scalars['ID']['output']; + signature: Scalars['String']['output']; + updatedAt: Scalars['String']['output']; +}; + export type OutgoingPaymentConnection = { __typename?: 'OutgoingPaymentConnection'; /** A list of outgoing payment edges, containing outgoing payment nodes and cursors for pagination. */ @@ -1898,7 +1919,7 @@ export type DirectiveResolverFn> = { BasePayment: ( Partial ) | ( Partial ) | ( Partial ); - Model: ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ); + Model: ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ); }; /** Mapping between all available schema types and the resolvers types */ @@ -1921,6 +1942,7 @@ export type ResolversTypes = { CancelIncomingPaymentInput: ResolverTypeWrapper>; CancelIncomingPaymentResponse: ResolverTypeWrapper>; CancelOutgoingPaymentInput: ResolverTypeWrapper>; + CardDetailsInput: ResolverTypeWrapper>; CreateAssetInput: ResolverTypeWrapper>; CreateAssetLiquidityWithdrawalInput: ResolverTypeWrapper>; CreateIncomingPaymentInput: ResolverTypeWrapper>; @@ -1982,6 +2004,7 @@ export type ResolversTypes = { Model: ResolverTypeWrapper['Model']>; Mutation: ResolverTypeWrapper<{}>; OutgoingPayment: ResolverTypeWrapper>; + OutgoingPaymentCardDetails: ResolverTypeWrapper>; OutgoingPaymentConnection: ResolverTypeWrapper>; OutgoingPaymentEdge: ResolverTypeWrapper>; OutgoingPaymentFilter: ResolverTypeWrapper>; @@ -2065,6 +2088,7 @@ export type ResolversParentTypes = { CancelIncomingPaymentInput: Partial; CancelIncomingPaymentResponse: Partial; CancelOutgoingPaymentInput: Partial; + CardDetailsInput: Partial; CreateAssetInput: Partial; CreateAssetLiquidityWithdrawalInput: Partial; CreateIncomingPaymentInput: Partial; @@ -2121,6 +2145,7 @@ export type ResolversParentTypes = { Model: ResolversInterfaceTypes['Model']; Mutation: {}; OutgoingPayment: Partial; + OutgoingPaymentCardDetails: Partial; OutgoingPaymentConnection: Partial; OutgoingPaymentEdge: Partial; OutgoingPaymentFilter: Partial; @@ -2392,7 +2417,7 @@ export type LiquidityMutationResponseResolvers = { - __resolveType: TypeResolveFn<'AccountingTransfer' | 'Asset' | 'Fee' | 'IncomingPayment' | 'OutgoingPayment' | 'Payment' | 'Peer' | 'Tenant' | 'WalletAddress' | 'WalletAddressKey' | 'WebhookEvent', ParentType, ContextType>; + __resolveType: TypeResolveFn<'AccountingTransfer' | 'Asset' | 'Fee' | 'IncomingPayment' | 'OutgoingPayment' | 'OutgoingPaymentCardDetails' | 'Payment' | 'Peer' | 'Tenant' | 'WalletAddress' | 'WalletAddressKey' | 'WebhookEvent', ParentType, ContextType>; createdAt?: Resolver; id?: Resolver; }; @@ -2439,6 +2464,7 @@ export type MutationResolvers = { + cardDetails?: Resolver, ParentType, ContextType>; client?: Resolver, ParentType, ContextType>; createdAt?: Resolver; debitAmount?: Resolver; @@ -2458,6 +2484,16 @@ export type OutgoingPaymentResolvers; }; +export type OutgoingPaymentCardDetailsResolvers = { + createdAt?: Resolver; + expiry?: Resolver; + id?: Resolver; + outgoingPaymentId?: Resolver; + signature?: Resolver; + updatedAt?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type OutgoingPaymentConnectionResolvers = { edges?: Resolver, ParentType, ContextType>; pageInfo?: Resolver; @@ -2792,6 +2828,7 @@ export type Resolvers = { Model?: ModelResolvers; Mutation?: MutationResolvers; OutgoingPayment?: OutgoingPaymentResolvers; + OutgoingPaymentCardDetails?: OutgoingPaymentCardDetailsResolvers; OutgoingPaymentConnection?: OutgoingPaymentConnectionResolvers; OutgoingPaymentEdge?: OutgoingPaymentEdgeResolvers; OutgoingPaymentResponse?: OutgoingPaymentResponseResolvers; diff --git a/test/test-lib/src/generated/graphql.ts b/test/test-lib/src/generated/graphql.ts index 35e0959e56..4706d84425 100644 --- a/test/test-lib/src/generated/graphql.ts +++ b/test/test-lib/src/generated/graphql.ts @@ -191,6 +191,13 @@ export type CancelOutgoingPaymentInput = { reason?: InputMaybe; }; +export type CardDetailsInput = { + /** Expire date */ + expiry: Scalars['String']['input']; + /** Signature */ + signature: Scalars['String']['input']; +}; + export type CreateAssetInput = { /** Should be an ISO 4217 currency code whenever possible, e.g. `USD`. For more information, refer to [assets](https://rafiki.dev/overview/concepts/accounting/#assets). */ code: Scalars['String']['input']; @@ -278,6 +285,8 @@ export type CreateOutgoingPaymentFromIncomingPaymentInput = { }; export type CreateOutgoingPaymentInput = { + /** Used for the card service to provide the card expiry and signature */ + cardDetails?: InputMaybe; /** Unique key to ensure duplicate or retried requests are processed only once. For more information, refer to [idempotency](https://rafiki.dev/apis/graphql/admin-api-overview/#idempotency). */ idempotencyKey?: InputMaybe; /** Additional metadata associated with the outgoing payment. */ @@ -1006,6 +1015,8 @@ export type MutationWithdrawEventLiquidityArgs = { export type OutgoingPayment = BasePayment & Model & { __typename?: 'OutgoingPayment'; + /** Used for the card service to provide the card expiry and signature */ + cardDetails?: Maybe; /** Information about the wallet address of the Open Payments client that created the outgoing payment. */ client?: Maybe; /** The date and time that the outgoing payment was created. */ @@ -1040,6 +1051,16 @@ export type OutgoingPayment = BasePayment & Model & { walletAddressId: Scalars['ID']['output']; }; +export type OutgoingPaymentCardDetails = Model & { + __typename?: 'OutgoingPaymentCardDetails'; + createdAt: Scalars['String']['output']; + expiry: Scalars['String']['output']; + id: Scalars['ID']['output']; + outgoingPaymentId: Scalars['ID']['output']; + signature: Scalars['String']['output']; + updatedAt: Scalars['String']['output']; +}; + export type OutgoingPaymentConnection = { __typename?: 'OutgoingPaymentConnection'; /** A list of outgoing payment edges, containing outgoing payment nodes and cursors for pagination. */ @@ -1898,7 +1919,7 @@ export type DirectiveResolverFn> = { BasePayment: ( Partial ) | ( Partial ) | ( Partial ); - Model: ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ); + Model: ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ) | ( Partial ); }; /** Mapping between all available schema types and the resolvers types */ @@ -1921,6 +1942,7 @@ export type ResolversTypes = { CancelIncomingPaymentInput: ResolverTypeWrapper>; CancelIncomingPaymentResponse: ResolverTypeWrapper>; CancelOutgoingPaymentInput: ResolverTypeWrapper>; + CardDetailsInput: ResolverTypeWrapper>; CreateAssetInput: ResolverTypeWrapper>; CreateAssetLiquidityWithdrawalInput: ResolverTypeWrapper>; CreateIncomingPaymentInput: ResolverTypeWrapper>; @@ -1982,6 +2004,7 @@ export type ResolversTypes = { Model: ResolverTypeWrapper['Model']>; Mutation: ResolverTypeWrapper<{}>; OutgoingPayment: ResolverTypeWrapper>; + OutgoingPaymentCardDetails: ResolverTypeWrapper>; OutgoingPaymentConnection: ResolverTypeWrapper>; OutgoingPaymentEdge: ResolverTypeWrapper>; OutgoingPaymentFilter: ResolverTypeWrapper>; @@ -2065,6 +2088,7 @@ export type ResolversParentTypes = { CancelIncomingPaymentInput: Partial; CancelIncomingPaymentResponse: Partial; CancelOutgoingPaymentInput: Partial; + CardDetailsInput: Partial; CreateAssetInput: Partial; CreateAssetLiquidityWithdrawalInput: Partial; CreateIncomingPaymentInput: Partial; @@ -2121,6 +2145,7 @@ export type ResolversParentTypes = { Model: ResolversInterfaceTypes['Model']; Mutation: {}; OutgoingPayment: Partial; + OutgoingPaymentCardDetails: Partial; OutgoingPaymentConnection: Partial; OutgoingPaymentEdge: Partial; OutgoingPaymentFilter: Partial; @@ -2392,7 +2417,7 @@ export type LiquidityMutationResponseResolvers = { - __resolveType: TypeResolveFn<'AccountingTransfer' | 'Asset' | 'Fee' | 'IncomingPayment' | 'OutgoingPayment' | 'Payment' | 'Peer' | 'Tenant' | 'WalletAddress' | 'WalletAddressKey' | 'WebhookEvent', ParentType, ContextType>; + __resolveType: TypeResolveFn<'AccountingTransfer' | 'Asset' | 'Fee' | 'IncomingPayment' | 'OutgoingPayment' | 'OutgoingPaymentCardDetails' | 'Payment' | 'Peer' | 'Tenant' | 'WalletAddress' | 'WalletAddressKey' | 'WebhookEvent', ParentType, ContextType>; createdAt?: Resolver; id?: Resolver; }; @@ -2439,6 +2464,7 @@ export type MutationResolvers = { + cardDetails?: Resolver, ParentType, ContextType>; client?: Resolver, ParentType, ContextType>; createdAt?: Resolver; debitAmount?: Resolver; @@ -2458,6 +2484,16 @@ export type OutgoingPaymentResolvers; }; +export type OutgoingPaymentCardDetailsResolvers = { + createdAt?: Resolver; + expiry?: Resolver; + id?: Resolver; + outgoingPaymentId?: Resolver; + signature?: Resolver; + updatedAt?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type OutgoingPaymentConnectionResolvers = { edges?: Resolver, ParentType, ContextType>; pageInfo?: Resolver; @@ -2792,6 +2828,7 @@ export type Resolvers = { Model?: ModelResolvers; Mutation?: MutationResolvers; OutgoingPayment?: OutgoingPaymentResolvers; + OutgoingPaymentCardDetails?: OutgoingPaymentCardDetailsResolvers; OutgoingPaymentConnection?: OutgoingPaymentConnectionResolvers; OutgoingPaymentEdge?: OutgoingPaymentEdgeResolvers; OutgoingPaymentResponse?: OutgoingPaymentResponseResolvers; From d984fff2d56df39451a9ae9e1fd92f9c070107b5 Mon Sep 17 00:00:00 2001 From: Raul Ranete Date: Wed, 9 Jul 2025 22:20:03 +0300 Subject: [PATCH 2/8] fix(backend): save expiry as string MM/YY --- .../20250708070748_create_outgoing_payment_card_table.js | 2 +- packages/backend/src/graphql/resolvers/outgoing_payment.ts | 2 +- .../backend/src/open_payments/payment/outgoing/card/model.ts | 2 +- packages/backend/src/open_payments/payment/outgoing/service.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/backend/migrations/20250708070748_create_outgoing_payment_card_table.js b/packages/backend/migrations/20250708070748_create_outgoing_payment_card_table.js index 8053bb92a8..d19abdbcb9 100644 --- a/packages/backend/migrations/20250708070748_create_outgoing_payment_card_table.js +++ b/packages/backend/migrations/20250708070748_create_outgoing_payment_card_table.js @@ -6,7 +6,7 @@ exports.up = function(knex) { return knex.schema.createTable('outgoingPaymentCardDetails', function(table) { table.uuid('id').notNullable().primary() table.string('signature').notNullable(); - table.timestamp('expiry').notNullable(); + table.string('expiry').notNullable(); table.uuid('outgoingPaymentId').notNullable(); table.foreign('outgoingPaymentId') diff --git a/packages/backend/src/graphql/resolvers/outgoing_payment.ts b/packages/backend/src/graphql/resolvers/outgoing_payment.ts index 3a49fb6059..9af0cad448 100644 --- a/packages/backend/src/graphql/resolvers/outgoing_payment.ts +++ b/packages/backend/src/graphql/resolvers/outgoing_payment.ts @@ -259,7 +259,7 @@ function cardToGraphql(payment: OutgoingPayment): OutgoingPaymentCardDetails | u return { id: payment.cardDetails.id, outgoingPaymentId: payment.cardDetails.outgoingPaymentId, - expiry: new Date(+payment.cardDetails.expiry).toISOString(), + expiry: payment.cardDetails.expiry, signature: payment.cardDetails.signature, updatedAt: new Date(+payment.cardDetails.updatedAt).toISOString(), createdAt: new Date(+payment.cardDetails.createdAt).toISOString() diff --git a/packages/backend/src/open_payments/payment/outgoing/card/model.ts b/packages/backend/src/open_payments/payment/outgoing/card/model.ts index 5475866e6a..a0f42c83ed 100644 --- a/packages/backend/src/open_payments/payment/outgoing/card/model.ts +++ b/packages/backend/src/open_payments/payment/outgoing/card/model.ts @@ -6,7 +6,7 @@ export class OutgoingPaymentsCardDetails extends BaseModel { return 'outgoingPaymentCardDetails' } - public expiry!: Date + public expiry!: string public readonly outgoingPaymentId!: string public signature!: string diff --git a/packages/backend/src/open_payments/payment/outgoing/service.ts b/packages/backend/src/open_payments/payment/outgoing/service.ts index d47518439f..ded044e5f9 100644 --- a/packages/backend/src/open_payments/payment/outgoing/service.ts +++ b/packages/backend/src/open_payments/payment/outgoing/service.ts @@ -419,7 +419,7 @@ async function createOutgoingPayment( trx ).insertAndFetch({ outgoingPaymentId: payment.id, - expiry: new Date(options.cardDetails?.expiry), + expiry: options.cardDetails?.expiry, signature: options.cardDetails?.signature }) payment.cardDetails = card From 40b8ad2adc133d7f1ee197564d72b0803c54f8ac Mon Sep 17 00:00:00 2001 From: Raul Ranete Date: Thu, 10 Jul 2025 10:08:05 +0300 Subject: [PATCH 3/8] feature(backend): add mm/yy validation --- .../open_payments/payment/outgoing/errors.ts | 14 +++++----- .../open_payments/payment/outgoing/service.ts | 26 ++++++++++++------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/packages/backend/src/open_payments/payment/outgoing/errors.ts b/packages/backend/src/open_payments/payment/outgoing/errors.ts index b368843500..ecdf95a4a7 100644 --- a/packages/backend/src/open_payments/payment/outgoing/errors.ts +++ b/packages/backend/src/open_payments/payment/outgoing/errors.ts @@ -18,7 +18,8 @@ export enum OutgoingPaymentError { InvalidAmount = 'InvalidAmount', NegativeReceiveAmount = 'NegativeReceiveAmount', InvalidReceiver = 'InvalidReceiver', - OnlyOneGrantAmountAllowed = 'OnlyOneGrantAmountAllowed' + OnlyOneGrantAmountAllowed = 'OnlyOneGrantAmountAllowed', + InvalidCardExpiry = 'InvalidCardExpiry' } export const quoteErrorToOutgoingPaymentError: Record< @@ -52,7 +53,8 @@ export const errorToHTTPCode: { [OutgoingPaymentError.InvalidAmount]: 400, [OutgoingPaymentError.NegativeReceiveAmount]: 400, [OutgoingPaymentError.InvalidReceiver]: 400, - [OutgoingPaymentError.OnlyOneGrantAmountAllowed]: 500 + [OutgoingPaymentError.OnlyOneGrantAmountAllowed]: 500, + [OutgoingPaymentError.InvalidCardExpiry]: 400 } export const errorToCode: { @@ -68,8 +70,8 @@ export const errorToCode: { [OutgoingPaymentError.InvalidAmount]: GraphQLErrorCode.BadUserInput, [OutgoingPaymentError.NegativeReceiveAmount]: GraphQLErrorCode.BadUserInput, [OutgoingPaymentError.InvalidReceiver]: GraphQLErrorCode.BadUserInput, - [OutgoingPaymentError.OnlyOneGrantAmountAllowed]: - GraphQLErrorCode.BadUserInput + [OutgoingPaymentError.OnlyOneGrantAmountAllowed]: GraphQLErrorCode.BadUserInput, + [OutgoingPaymentError.InvalidCardExpiry]: GraphQLErrorCode.BadUserInput } export const errorToMessage: { @@ -85,8 +87,8 @@ export const errorToMessage: { [OutgoingPaymentError.InvalidAmount]: 'invalid amount', [OutgoingPaymentError.NegativeReceiveAmount]: 'negative receive amount', [OutgoingPaymentError.InvalidReceiver]: 'invalid receiver', - [OutgoingPaymentError.OnlyOneGrantAmountAllowed]: - 'only one of receiveAmount or debitAmount allowed' + [OutgoingPaymentError.OnlyOneGrantAmountAllowed]: 'only one of receiveAmount or debitAmount allowed', + [OutgoingPaymentError.InvalidCardExpiry]: 'expired card format MM/YY' } export const FundingError = { ...OutgoingPaymentError, ...TransferError } diff --git a/packages/backend/src/open_payments/payment/outgoing/service.ts b/packages/backend/src/open_payments/payment/outgoing/service.ts index ded044e5f9..bf139591ae 100644 --- a/packages/backend/src/open_payments/payment/outgoing/service.ts +++ b/packages/backend/src/open_payments/payment/outgoing/service.ts @@ -415,15 +415,17 @@ async function createOutgoingPayment( }) if (isCreateFromCardPayment(options)) { - const card = await OutgoingPaymentsCardDetails.query( - trx - ).insertAndFetch({ - outgoingPaymentId: payment.id, - expiry: options.cardDetails?.expiry, - signature: options.cardDetails?.signature - }) - payment.cardDetails = card - } + const { expiry, signature } = options.cardDetails; + + if (!isExpiryFormat(expiry)) + throw OutgoingPaymentError.InvalidCardExpiry; + + payment.cardDetails = await OutgoingPaymentsCardDetails.query(trx).insertAndFetch({ + outgoingPaymentId: payment.id, + expiry, + signature + }); + } payment.walletAddress = walletAddress payment.quote = quote @@ -845,3 +847,9 @@ function validateSentAmount( ) throw new Error(errorMessage) } + +function isExpiryFormat(expiry: string): boolean { + return !!expiry.match(/^(0[1-9]|1[0-2])\/(\d{2})$/) + +} + From 6918649951b406c6f28ee1d9578f2b3d92e29ce1 Mon Sep 17 00:00:00 2001 From: Raul Ranete Date: Thu, 10 Jul 2025 10:31:10 +0300 Subject: [PATCH 4/8] fix(backend): run prittier --- ...0748_create_outgoing_payment_card_table.js | 38 ++++++++++--------- .../src/graphql/resolvers/outgoing_payment.ts | 8 ++-- packages/backend/src/graphql/schema.graphql | 3 +- .../open_payments/payment/outgoing/errors.ts | 6 ++- .../open_payments/payment/outgoing/model.ts | 18 ++++----- .../open_payments/payment/outgoing/service.ts | 24 +++++++----- 6 files changed, 52 insertions(+), 45 deletions(-) diff --git a/packages/backend/migrations/20250708070748_create_outgoing_payment_card_table.js b/packages/backend/migrations/20250708070748_create_outgoing_payment_card_table.js index d19abdbcb9..5ba45af9f7 100644 --- a/packages/backend/migrations/20250708070748_create_outgoing_payment_card_table.js +++ b/packages/backend/migrations/20250708070748_create_outgoing_payment_card_table.js @@ -2,27 +2,31 @@ * @param { import("knex").Knex } knex * @returns { Promise } */ -exports.up = function(knex) { - return knex.schema.createTable('outgoingPaymentCardDetails', function(table) { - table.uuid('id').notNullable().primary() - table.string('signature').notNullable(); - table.string('expiry').notNullable(); - table.uuid('outgoingPaymentId').notNullable(); +exports.up = function (knex) { + return knex.schema.createTable( + 'outgoingPaymentCardDetails', + function (table) { + table.uuid('id').notNullable().primary() + table.string('signature').notNullable() + table.string('expiry').notNullable() + table.uuid('outgoingPaymentId').notNullable() - table.foreign('outgoingPaymentId') - .references('id') - .inTable('outgoingPayments') - .onDelete('CASCADE'); + table + .foreign('outgoingPaymentId') + .references('id') + .inTable('outgoingPayments') + .onDelete('CASCADE') - table.timestamp('createdAt').defaultTo(knex.fn.now()) - table.timestamp('updatedAt').defaultTo(knex.fn.now()) - }); -}; + table.timestamp('createdAt').defaultTo(knex.fn.now()) + table.timestamp('updatedAt').defaultTo(knex.fn.now()) + } + ) +} /** * @param { import("knex").Knex } knex * @returns { Promise } */ -exports.down = function(knex) { - return knex.schema.dropTableIfExists('outgoingPaymentCardDetails'); -}; +exports.down = function (knex) { + return knex.schema.dropTableIfExists('outgoingPaymentCardDetails') +} diff --git a/packages/backend/src/graphql/resolvers/outgoing_payment.ts b/packages/backend/src/graphql/resolvers/outgoing_payment.ts index 9af0cad448..b50196dfad 100644 --- a/packages/backend/src/graphql/resolvers/outgoing_payment.ts +++ b/packages/backend/src/graphql/resolvers/outgoing_payment.ts @@ -253,9 +253,10 @@ export function paymentToGraphql( cardDetails: cardToGraphql(payment) } } -function cardToGraphql(payment: OutgoingPayment): OutgoingPaymentCardDetails | undefined { - if (!payment.cardDetails) - return undefined +function cardToGraphql( + payment: OutgoingPayment +): OutgoingPaymentCardDetails | undefined { + if (!payment.cardDetails) return undefined return { id: payment.cardDetails.id, outgoingPaymentId: payment.cardDetails.outgoingPaymentId, @@ -265,4 +266,3 @@ function cardToGraphql(payment: OutgoingPayment): OutgoingPaymentCardDetails | u createdAt: new Date(+payment.cardDetails.createdAt).toISOString() } } - diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index b14ce3a23e..3da7676dbd 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -1051,7 +1051,7 @@ type OutgoingPayment implements BasePayment & Model { "Unique identifier of the grant under which the outgoing payment was created." grantId: String "Tenant ID of the outgoing payment." - tenantId: String, + tenantId: String "Used for the card service to provide the card expiry and signature" cardDetails: OutgoingPaymentCardDetails } @@ -1063,7 +1063,6 @@ type OutgoingPaymentCardDetails implements Model { expiry: String! createdAt: String! updatedAt: String! - } enum OutgoingPaymentState { diff --git a/packages/backend/src/open_payments/payment/outgoing/errors.ts b/packages/backend/src/open_payments/payment/outgoing/errors.ts index ecdf95a4a7..2fae256e54 100644 --- a/packages/backend/src/open_payments/payment/outgoing/errors.ts +++ b/packages/backend/src/open_payments/payment/outgoing/errors.ts @@ -70,7 +70,8 @@ export const errorToCode: { [OutgoingPaymentError.InvalidAmount]: GraphQLErrorCode.BadUserInput, [OutgoingPaymentError.NegativeReceiveAmount]: GraphQLErrorCode.BadUserInput, [OutgoingPaymentError.InvalidReceiver]: GraphQLErrorCode.BadUserInput, - [OutgoingPaymentError.OnlyOneGrantAmountAllowed]: GraphQLErrorCode.BadUserInput, + [OutgoingPaymentError.OnlyOneGrantAmountAllowed]: + GraphQLErrorCode.BadUserInput, [OutgoingPaymentError.InvalidCardExpiry]: GraphQLErrorCode.BadUserInput } @@ -87,7 +88,8 @@ export const errorToMessage: { [OutgoingPaymentError.InvalidAmount]: 'invalid amount', [OutgoingPaymentError.NegativeReceiveAmount]: 'negative receive amount', [OutgoingPaymentError.InvalidReceiver]: 'invalid receiver', - [OutgoingPaymentError.OnlyOneGrantAmountAllowed]: 'only one of receiveAmount or debitAmount allowed', + [OutgoingPaymentError.OnlyOneGrantAmountAllowed]: + 'only one of receiveAmount or debitAmount allowed', [OutgoingPaymentError.InvalidCardExpiry]: 'expired card format MM/YY' } diff --git a/packages/backend/src/open_payments/payment/outgoing/model.ts b/packages/backend/src/open_payments/payment/outgoing/model.ts index 1c0fd7e1ef..edc9883489 100644 --- a/packages/backend/src/open_payments/payment/outgoing/model.ts +++ b/packages/backend/src/open_payments/payment/outgoing/model.ts @@ -102,7 +102,7 @@ export class OutgoingPayment public metadata?: Record - public cardDetails?: OutgoingPaymentsCardDetails; + public cardDetails?: OutgoingPaymentsCardDetails public quote!: Quote @@ -156,9 +156,8 @@ export class OutgoingPayment join: { from: 'outgoingPayments.id', to: 'outgoingPaymentsCardDetails.outgoingPaymentId' - } - }, + } } } @@ -329,12 +328,11 @@ export class OutgoingPaymentEvent extends WebhookEvent { } } - $formatJson(json: Pojo): Pojo { - json = super.$formatJson(json) - return { - ...json, - cardDetails: new OutgoingPaymentsCardDetails().$formatJson(json.card), - } + $formatJson(json: Pojo): Pojo { + json = super.$formatJson(json) + return { + ...json, + cardDetails: new OutgoingPaymentsCardDetails().$formatJson(json.card) } } - +} diff --git a/packages/backend/src/open_payments/payment/outgoing/service.ts b/packages/backend/src/open_payments/payment/outgoing/service.ts index bf139591ae..4dc054cb45 100644 --- a/packages/backend/src/open_payments/payment/outgoing/service.ts +++ b/packages/backend/src/open_payments/payment/outgoing/service.ts @@ -223,7 +223,11 @@ export type CreateOutgoingPaymentOptions = export function isCreateFromCardPayment( options: CreateOutgoingPaymentOptions ): options is CreateFromCardPayment { - return 'cardDetails' in options && 'expiry' in options.cardDetails && 'signature' in options.cardDetails + return ( + 'cardDetails' in options && + 'expiry' in options.cardDetails && + 'signature' in options.cardDetails + ) } export function isCreateFromIncomingPayment( @@ -415,16 +419,18 @@ async function createOutgoingPayment( }) if (isCreateFromCardPayment(options)) { - const { expiry, signature } = options.cardDetails; - - if (!isExpiryFormat(expiry)) - throw OutgoingPaymentError.InvalidCardExpiry; - - payment.cardDetails = await OutgoingPaymentsCardDetails.query(trx).insertAndFetch({ + const { expiry, signature } = options.cardDetails + + if (!isExpiryFormat(expiry)) + throw OutgoingPaymentError.InvalidCardExpiry + + payment.cardDetails = await OutgoingPaymentsCardDetails.query( + trx + ).insertAndFetch({ outgoingPaymentId: payment.id, expiry, signature - }); + }) } payment.walletAddress = walletAddress @@ -850,6 +856,4 @@ function validateSentAmount( function isExpiryFormat(expiry: string): boolean { return !!expiry.match(/^(0[1-9]|1[0-2])\/(\d{2})$/) - } - From f72a042dba29cd63d8e95e95cf32d21ac446d118 Mon Sep 17 00:00:00 2001 From: Raul Ranete Date: Thu, 10 Jul 2025 12:49:28 +0300 Subject: [PATCH 5/8] fix(backend): comments and set type to table name and relation --- .../src/graphql/resolvers/outgoing_payment.ts | 25 ++++++++-------- .../payment/outgoing/card/model.ts | 30 ++++++++++++------- .../open_payments/payment/outgoing/model.ts | 19 +++++------- .../open_payments/payment/outgoing/service.ts | 4 +-- 4 files changed, 41 insertions(+), 37 deletions(-) diff --git a/packages/backend/src/graphql/resolvers/outgoing_payment.ts b/packages/backend/src/graphql/resolvers/outgoing_payment.ts index b50196dfad..b127ec5ffc 100644 --- a/packages/backend/src/graphql/resolvers/outgoing_payment.ts +++ b/packages/backend/src/graphql/resolvers/outgoing_payment.ts @@ -5,7 +5,7 @@ import { WalletAddressResolvers, QueryResolvers, ResolversTypes, - OutgoingPaymentCardDetails + OutgoingPaymentCardDetails as SchemaOutgoingPaymentCardDetails } from '../generated/graphql' import { isOutgoingPaymentError, @@ -18,6 +18,7 @@ import { getPageInfo } from '../../shared/pagination' import { Pagination, SortOrder } from '../../shared/baseModel' import { GraphQLError } from 'graphql' import { GraphQLErrorCode } from '../errors' +import { OutgoingPaymentCardDetails } from '../../open_payments/payment/outgoing/card/model' export const getOutgoingPayment: QueryResolvers['outgoingPayment'] = async (parent, args, ctx): Promise => { @@ -250,19 +251,19 @@ export function paymentToGraphql( quote: quoteToGraphql(payment.quote), grantId: payment.grantId, tenantId: payment.tenantId, - cardDetails: cardToGraphql(payment) + cardDetails: cardDetailsToGraphql(payment.cardDetails) } } -function cardToGraphql( - payment: OutgoingPayment -): OutgoingPaymentCardDetails | undefined { - if (!payment.cardDetails) return undefined +function cardDetailsToGraphql( + cardDetails?: OutgoingPaymentCardDetails +): SchemaOutgoingPaymentCardDetails | undefined { + if (!cardDetails) return undefined return { - id: payment.cardDetails.id, - outgoingPaymentId: payment.cardDetails.outgoingPaymentId, - expiry: payment.cardDetails.expiry, - signature: payment.cardDetails.signature, - updatedAt: new Date(+payment.cardDetails.updatedAt).toISOString(), - createdAt: new Date(+payment.cardDetails.createdAt).toISOString() + id: cardDetails.id, + outgoingPaymentId: cardDetails.outgoingPaymentId, + expiry: cardDetails.expiry, + signature: cardDetails.signature, + createdAt: new Date(+cardDetails.createdAt).toISOString(), + updatedAt: new Date(+cardDetails.updatedAt).toISOString() } } diff --git a/packages/backend/src/open_payments/payment/outgoing/card/model.ts b/packages/backend/src/open_payments/payment/outgoing/card/model.ts index a0f42c83ed..d20d8c2ad9 100644 --- a/packages/backend/src/open_payments/payment/outgoing/card/model.ts +++ b/packages/backend/src/open_payments/payment/outgoing/card/model.ts @@ -1,20 +1,28 @@ -import { Pojo } from 'objection' import { BaseModel } from '../../../../shared/baseModel' -export class OutgoingPaymentsCardDetails extends BaseModel { - public static get tableName(): string { +type OutgoingPaymentCardDetailsTableName = 'outgoingPaymentCardDetails' +type OutgoingPaymentId = 'outgoingPaymentId' +type OutgoingPaymentIdRelation = + `${OutgoingPaymentCardDetailsTableName}.${OutgoingPaymentId}` +export const outgoingPaymentCardDetailsRelation: OutgoingPaymentIdRelation = + 'outgoingPaymentCardDetails.outgoingPaymentId' + +type OutgoingPaymentCardDetailsType = { + expiry: string + signature: string +} & { + [key in OutgoingPaymentId]: string +} + +export class OutgoingPaymentCardDetails + extends BaseModel + implements OutgoingPaymentCardDetailsType +{ + public static get tableName(): OutgoingPaymentCardDetailsTableName { return 'outgoingPaymentCardDetails' } public expiry!: string public readonly outgoingPaymentId!: string public signature!: string - - $formatJson(json: Pojo): Pojo { - json = super.$formatJson(json) - return { - ...json, - expiry: json.expiry.toISOString() - } - } } diff --git a/packages/backend/src/open_payments/payment/outgoing/model.ts b/packages/backend/src/open_payments/payment/outgoing/model.ts index edc9883489..53d7096733 100644 --- a/packages/backend/src/open_payments/payment/outgoing/model.ts +++ b/packages/backend/src/open_payments/payment/outgoing/model.ts @@ -16,7 +16,10 @@ import { OutgoingPaymentWithSpentAmounts } from '@interledger/open-payments' import { Tenant } from '../../../tenants/model' -import { OutgoingPaymentsCardDetails } from './card/model' +import { + OutgoingPaymentCardDetails, + outgoingPaymentCardDetailsRelation +} from './card/model' export class OutgoingPaymentGrant extends DbErrors(Model) { public static get modelPaths(): string[] { @@ -102,7 +105,7 @@ export class OutgoingPayment public metadata?: Record - public cardDetails?: OutgoingPaymentsCardDetails + public cardDetails?: OutgoingPaymentCardDetails public quote!: Quote @@ -152,10 +155,10 @@ export class OutgoingPayment }, cardDetails: { relation: Model.HasOneRelation, - modelClass: OutgoingPaymentsCardDetails, + modelClass: OutgoingPaymentCardDetails, join: { from: 'outgoingPayments.id', - to: 'outgoingPaymentsCardDetails.outgoingPaymentId' + to: outgoingPaymentCardDetailsRelation } } } @@ -327,12 +330,4 @@ export class OutgoingPaymentEvent extends WebhookEvent { throw new Error(OutgoingPaymentEventError.OutgoingPaymentIdRequired) } } - - $formatJson(json: Pojo): Pojo { - json = super.$formatJson(json) - return { - ...json, - cardDetails: new OutgoingPaymentsCardDetails().$formatJson(json.card) - } - } } diff --git a/packages/backend/src/open_payments/payment/outgoing/service.ts b/packages/backend/src/open_payments/payment/outgoing/service.ts index 4dc054cb45..d23b3cdc3d 100644 --- a/packages/backend/src/open_payments/payment/outgoing/service.ts +++ b/packages/backend/src/open_payments/payment/outgoing/service.ts @@ -46,7 +46,7 @@ import { IAppConfig } from '../../../config/app' import { AssetService } from '../../../asset/service' import { Span, trace } from '@opentelemetry/api' import { FeeService } from '../../../fee/service' -import { OutgoingPaymentsCardDetails } from './card/model' +import { OutgoingPaymentCardDetails } from './card/model' export interface OutgoingPaymentService extends WalletAddressSubresourceService { @@ -424,7 +424,7 @@ async function createOutgoingPayment( if (!isExpiryFormat(expiry)) throw OutgoingPaymentError.InvalidCardExpiry - payment.cardDetails = await OutgoingPaymentsCardDetails.query( + payment.cardDetails = await OutgoingPaymentCardDetails.query( trx ).insertAndFetch({ outgoingPaymentId: payment.id, From a2a9bcdba3f9b1c0557d093b80320ecb9c4e96e5 Mon Sep 17 00:00:00 2001 From: Raul Ranete Date: Thu, 10 Jul 2025 12:52:12 +0300 Subject: [PATCH 6/8] fix(backend): remove unused deps --- packages/backend/src/open_payments/payment/outgoing/model.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/open_payments/payment/outgoing/model.ts b/packages/backend/src/open_payments/payment/outgoing/model.ts index 53d7096733..5056bd6179 100644 --- a/packages/backend/src/open_payments/payment/outgoing/model.ts +++ b/packages/backend/src/open_payments/payment/outgoing/model.ts @@ -1,4 +1,4 @@ -import { Model, ModelOptions, Pojo, QueryContext } from 'objection' +import { Model, ModelOptions, QueryContext } from 'objection' import { DbErrors } from 'objection-db-errors' import { LiquidityAccount } from '../../../accounting/service' From ec906a63e1762e2a1ee6d8b362433b3a5f65d1e0 Mon Sep 17 00:00:00 2001 From: Raul Ranete Date: Thu, 10 Jul 2025 17:27:48 +0300 Subject: [PATCH 7/8] feat(backend): add tests for outgoing payments with card details --- .../resolvers/outgoing_payment.test.ts | 96 +++++++++++++++++++ .../payment/outgoing/service.test.ts | 39 +++++++- 2 files changed, 134 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/graphql/resolvers/outgoing_payment.test.ts b/packages/backend/src/graphql/resolvers/outgoing_payment.test.ts index 3d873333a2..41425c8c0b 100644 --- a/packages/backend/src/graphql/resolvers/outgoing_payment.test.ts +++ b/packages/backend/src/graphql/resolvers/outgoing_payment.test.ts @@ -713,6 +713,102 @@ describe('OutgoingPayment Resolvers', (): void => { } expect(createSpy).toHaveBeenCalledWith({ ...input, tenantId }) }) + test('fails because of card expiry', async (): Promise => { + const createSpy = jest + .spyOn(outgoingPaymentService, 'create') + .mockResolvedValueOnce(OutgoingPaymentError.InvalidCardExpiry) + + const input = { + walletAddressId: uuid(), + quoteId: uuid(), + cardDetails: { + signature: 'test-signature', + expiry: 'NOT-A-VALID-EXPIRY' + } + } + + expect.assertions(3) + try { + await appContainer.apolloClient + .query({ + query: gql` + mutation CreateOutgoingPayment( + $input: CreateOutgoingPaymentInput! + ) { + createOutgoingPayment(input: $input) { + payment { + id + state + } + } + } + `, + variables: { input } + }) + .then( + (query): OutgoingPaymentResponse => + query.data?.createOutgoingPayment + ) + } catch (error) { + expect(error).toBeInstanceOf(ApolloError) + expect((error as ApolloError).graphQLErrors).toContainEqual( + expect.objectContaining({ + message: errorToMessage[OutgoingPaymentError.InvalidCardExpiry], + extensions: expect.objectContaining({ + code: errorToCode[OutgoingPaymentError.InvalidCardExpiry] + }) + }) + ) + } + expect(createSpy).toHaveBeenCalledWith({ ...input, tenantId }) + }) + test('works with card details', async (): Promise => { + const { id: walletAddressId } = await createWalletAddress(deps, { + tenantId, + assetId: asset.id + }) + const payment = await createPayment({ + tenantId, + walletAddressId + }) + + const createSpy = jest + .spyOn(outgoingPaymentService, 'create') + .mockResolvedValueOnce(payment) + + const input = { + walletAddressId: payment.walletAddressId, + quoteId: payment.quote.id, + cardDetails: { + signature: 'test-signature', + expiry: '12/24' + } + } + + const query = await appContainer.apolloClient + .query({ + query: gql` + mutation CreateOutgoingPayment( + $input: CreateOutgoingPaymentInput! + ) { + createOutgoingPayment(input: $input) { + payment { + id + state + } + } + } + `, + variables: { input } + }) + .then( + (query): OutgoingPaymentResponse => query.data?.createOutgoingPayment + ) + + expect(createSpy).toHaveBeenCalledWith({ ...input, tenantId }) + expect(query.payment?.id).toBe(payment.id) + expect(query.payment?.state).toBe(SchemaPaymentState.Funding) + }) }) describe('Mutation.createOutgoingPaymentFromIncomingPayment', (): void => { diff --git a/packages/backend/src/open_payments/payment/outgoing/service.test.ts b/packages/backend/src/open_payments/payment/outgoing/service.test.ts index 6d95713473..fc53968990 100644 --- a/packages/backend/src/open_payments/payment/outgoing/service.test.ts +++ b/packages/backend/src/open_payments/payment/outgoing/service.test.ts @@ -9,7 +9,11 @@ import { OutgoingPaymentError, isOutgoingPaymentError } from './errors' -import { CreateOutgoingPaymentOptions, OutgoingPaymentService } from './service' +import { + CreateFromCardPayment, + CreateOutgoingPaymentOptions, + OutgoingPaymentService +} from './service' import { createTestApp, TestContainer } from '../../../tests/app' import { Config, IAppConfig } from '../../../config/app' import { Grant } from '../../auth/middleware' @@ -1490,6 +1494,39 @@ describe('OutgoingPaymentService', (): void => { } ) }) + test('failed to create when expiry is not valid', async () => { + const paymentMethods: OpenPaymentsPaymentMethod[] = [ + { + type: 'ilp', + ilpAddress: 'test.ilp' as IlpAddress, + sharedSecret: '' + } + ] + const debitAmount = { + value: BigInt(123), + assetCode: receiverWalletAddress.asset.code, + assetScale: receiverWalletAddress.asset.scale + } + const options: CreateFromCardPayment = { + walletAddressId: receiverWalletAddress.id, + debitAmount, + incomingPayment: incomingPayment.toOpenPaymentsTypeWithMethods( + config.openPaymentsUrl, + receiverWalletAddress, + + paymentMethods + ).id, + tenantId, + cardDetails: { + expiry: 'invalid', + signature: 'test' + } + } + + const payment = await outgoingPaymentService.create(options) + expect(isOutgoingPaymentError(payment)).toBeTruthy() + expect(payment).toBe(OutgoingPaymentError.InvalidCardExpiry) + }) }) describe('processNext', (): void => { From 488785577a1746e752970f7dbe3a1e39aa961902 Mon Sep 17 00:00:00 2001 From: Raul Ranete Date: Fri, 11 Jul 2025 09:09:21 +0300 Subject: [PATCH 8/8] fix(backend): fix pr comments --- packages/backend/src/graphql/resolvers/outgoing_payment.ts | 6 ++++-- .../src/open_payments/payment/outgoing/card/model.ts | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/graphql/resolvers/outgoing_payment.ts b/packages/backend/src/graphql/resolvers/outgoing_payment.ts index b127ec5ffc..609f92fb04 100644 --- a/packages/backend/src/graphql/resolvers/outgoing_payment.ts +++ b/packages/backend/src/graphql/resolvers/outgoing_payment.ts @@ -251,11 +251,13 @@ export function paymentToGraphql( quote: quoteToGraphql(payment.quote), grantId: payment.grantId, tenantId: payment.tenantId, - cardDetails: cardDetailsToGraphql(payment.cardDetails) + cardDetails: payment.cardDetails + ? cardDetailsToGraphql(payment.cardDetails) + : undefined } } function cardDetailsToGraphql( - cardDetails?: OutgoingPaymentCardDetails + cardDetails: OutgoingPaymentCardDetails ): SchemaOutgoingPaymentCardDetails | undefined { if (!cardDetails) return undefined return { diff --git a/packages/backend/src/open_payments/payment/outgoing/card/model.ts b/packages/backend/src/open_payments/payment/outgoing/card/model.ts index d20d8c2ad9..faf6750d7e 100644 --- a/packages/backend/src/open_payments/payment/outgoing/card/model.ts +++ b/packages/backend/src/open_payments/payment/outgoing/card/model.ts @@ -1,9 +1,9 @@ import { BaseModel } from '../../../../shared/baseModel' type OutgoingPaymentCardDetailsTableName = 'outgoingPaymentCardDetails' -type OutgoingPaymentId = 'outgoingPaymentId' +type OutgoingPaymentIdColumnName = 'outgoingPaymentId' type OutgoingPaymentIdRelation = - `${OutgoingPaymentCardDetailsTableName}.${OutgoingPaymentId}` + `${OutgoingPaymentCardDetailsTableName}.${OutgoingPaymentIdColumnName}` export const outgoingPaymentCardDetailsRelation: OutgoingPaymentIdRelation = 'outgoingPaymentCardDetails.outgoingPaymentId' @@ -11,7 +11,7 @@ type OutgoingPaymentCardDetailsType = { expiry: string signature: string } & { - [key in OutgoingPaymentId]: string + [key in OutgoingPaymentIdColumnName]: string } export class OutgoingPaymentCardDetails