Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions localenv/mock-account-servicing-entity/generated/graphql.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function (knex) {
return knex.schema.alterTable('outgoingPaymentCardDetails', function (table) {
table.dropColumn('expiry')
table.dropColumn('signature')

table.uuid('requestId').notNullable()
table.jsonb('data').notNullable()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mkurapov is this the TLV data?
If it is, the ASE would need to extract the TLV data in order to retrieve the txn-counter and related TLV data.

Copy link
Contributor Author

@mkurapov mkurapov Sep 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@koekiebox data is just JSON for flexibility, and in practice it is

{
    "signature": "", // generate AC response
    "payload": "", // generate AC payload
}

such that the cardholder's ASE can fetch it from the webhook and validate it.

See this Slack thread for example payload

table.timestamp('initiatedAt').notNullable()
})
}

/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function (knex) {
return knex.schema.alterTable('outgoingPaymentCardDetails', function (table) {
table.dropColumn('requestId')
table.dropColumn('data')
table.dropColumn('initiatedAt')

table.string('signature').notNullable()
table.string('expiry').notNullable()
})
}
36 changes: 34 additions & 2 deletions packages/backend/src/graphql/generated/graphql.schema.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions packages/backend/src/graphql/generated/graphql.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions packages/backend/src/graphql/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -1248,8 +1248,9 @@ input CreateOutgoingPaymentInput {
}

input CardDetailsInput {
"Signature"
signature: String!
data: JSONObject!
requestId: String!
initiatedAt: String!
}

input CancelOutgoingPaymentInput {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ export const outgoingPaymentCardDetailsRelation: OutgoingPaymentIdRelation =
'outgoingPaymentCardDetails.outgoingPaymentId'

type OutgoingPaymentCardDetailsType = {
expiry: string
signature: string
requestId: string
data: Record<string, unknown>
initiatedAt: Date
} & {
[key in OutgoingPaymentIdColumnName]: string
}
Expand All @@ -22,7 +23,8 @@ export class OutgoingPaymentCardDetails
return 'outgoingPaymentCardDetails'
}

public expiry!: string
public readonly outgoingPaymentId!: string
public signature!: string
public requestId!: string
public data!: Record<string, unknown>
public initiatedAt!: Date
}
11 changes: 11 additions & 0 deletions packages/backend/src/open_payments/payment/outgoing/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,13 @@ export class OutgoingPayment
if (this.grantId) {
data.grantId = this.grantId
}
if (this.cardDetails) {
data.cardDetails = {
requestId: this.cardDetails.requestId,
data: this.cardDetails.data,
initiatedAt: this.cardDetails.initiatedAt
}
}
return data
}

Expand Down Expand Up @@ -296,6 +303,10 @@ export type PaymentData = Omit<OutgoingPaymentResponse, 'failed'> & {
stateAttempts: number
balance: string
grantId?: string
cardDetails?: Pick<
OutgoingPaymentCardDetails,
'requestId' | 'data' | 'initiatedAt'
>
}

export const isOutgoingPaymentEventType = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import {
} from '../../../tests/tenantSettings'
import { OpenPaymentsPaymentMethod } from '../../../payment-method/provider/service'
import { IlpAddress } from 'ilp-packet'
import { OutgoingPaymentCardDetails } from './card/model'

describe('OutgoingPaymentService', (): void => {
let deps: IocContract<AppServices>
Expand Down Expand Up @@ -1493,7 +1494,8 @@ describe('OutgoingPaymentService', (): void => {
}
)
})
test('failed to create when expiry is not valid', async () => {

test('stores card details when card payment', async () => {
const paymentMethods: OpenPaymentsPaymentMethod[] = [
{
type: 'ilp',
Expand All @@ -1512,19 +1514,28 @@ describe('OutgoingPaymentService', (): void => {
incomingPayment: incomingPayment.toOpenPaymentsTypeWithMethods(
config.openPaymentsUrl,
receiverWalletAddress,

paymentMethods
).id,
tenantId,
cardDetails: {
expiry: 'invalid',
signature: 'test'
requestId: crypto.randomUUID(),
initiatedAt: new Date(),
data: {
signature: 'signature',
payload: 'payload'
}
}
}

const payment = await outgoingPaymentService.create(options)
expect(isOutgoingPaymentError(payment)).toBeTruthy()
expect(payment).toBe(OutgoingPaymentError.InvalidCardExpiry)
const outgoingPayment = await outgoingPaymentService.create(options)

assert(outgoingPayment instanceof OutgoingPayment)

const cardDetails = await OutgoingPaymentCardDetails.query(knex).where({
outgoingPaymentId: outgoingPayment.id
})

expect(cardDetails).toHaveLength(1)
})
})

Expand Down
25 changes: 8 additions & 17 deletions packages/backend/src/open_payments/payment/outgoing/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,9 @@ export interface CreateFromIncomingPayment extends BaseOptions {

export interface CreateFromCardPayment extends CreateFromIncomingPayment {
cardDetails: {
expiry: string
signature: string
requestId: string
data: Record<string, unknown>
initiatedAt: Date
}
}

Expand All @@ -221,11 +222,7 @@ 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 && 'requestId' in options.cardDetails
}

export function isCreateFromIncomingPayment(
Expand Down Expand Up @@ -399,17 +396,15 @@ async function createOutgoingPayment(
})

if (isCreateFromCardPayment(options)) {
const { expiry, signature } = options.cardDetails

if (!isExpiryFormat(expiry))
throw OutgoingPaymentError.InvalidCardExpiry
const { data, requestId, initiatedAt } = options.cardDetails

payment.cardDetails = await OutgoingPaymentCardDetails.query(
trx
).insertAndFetch({
outgoingPaymentId: payment.id,
expiry,
signature
requestId,
data,
initiatedAt
})
}

Expand Down Expand Up @@ -823,7 +818,3 @@ function validateSentAmount(
)
throw new Error(errorMessage)
}

function isExpiryFormat(expiry: string): boolean {
return !!expiry.match(/^(0[1-9]|1[0-2])\/(\d{2})$/)
}
10 changes: 10 additions & 0 deletions packages/backend/src/openapi/specs/webhooks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,16 @@ components:
grantId:
type: string
format: uuid
cardDetails:
type: object
properties:
data:
type: object
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should data not be an ASCII-HEX string?

requestId:
type: string
initiatedAt:
type: string
format: date-time
additionalProperties: false
walletAddressNotFound:
required:
Expand Down
5 changes: 3 additions & 2 deletions packages/card-service/src/graphql/generated/graphql.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions packages/frontend/app/generated/graphql.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions packages/mock-account-service-lib/src/generated/graphql.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions packages/point-of-sale/src/graphql/generated/graphql.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions test/test-lib/src/generated/graphql.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading