From 9475f6122886ef3e7a2b9c6c0a4b544ccce69a30 Mon Sep 17 00:00:00 2001 From: mirunagherman Date: Mon, 22 Jun 2026 15:38:38 +0300 Subject: [PATCH 1/2] feat: add merchant onboarding field definitions API --- ...20260618154710_create_field_definitions.js | 55 ++++++++++++++++++ .../20260619142642_create_options.js | 44 ++++++++++++++ packages/wallet/backend/src/app.ts | 6 ++ .../wallet/backend/src/createContainer.ts | 8 ++- .../wallet/backend/src/terminal/controller.ts | 19 +++++++ packages/wallet/backend/src/terminal/model.ts | 57 +++++++++++++++++++ .../wallet/backend/src/terminal/service.ts | 41 +++++++++++++ 7 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 packages/wallet/backend/migrations/20260618154710_create_field_definitions.js create mode 100644 packages/wallet/backend/migrations/20260619142642_create_options.js create mode 100644 packages/wallet/backend/src/terminal/controller.ts create mode 100644 packages/wallet/backend/src/terminal/model.ts create mode 100644 packages/wallet/backend/src/terminal/service.ts diff --git a/packages/wallet/backend/migrations/20260618154710_create_field_definitions.js b/packages/wallet/backend/migrations/20260618154710_create_field_definitions.js new file mode 100644 index 000000000..fb6e67170 --- /dev/null +++ b/packages/wallet/backend/migrations/20260618154710_create_field_definitions.js @@ -0,0 +1,55 @@ +const fields = [ + { + key: 'contactEmail', + label: 'Contact email', + description: 'We use this to send onboarding confirmation.', + type: 'email', + required: true, + placeholder: 'me@interledger.org', + order: 2, + format: 'email', + maxLength: 255 + }, + { + id: '1acf7723-e1cd-44e7-a5db-3f614ce045ac', + key: 'merchantCategoryCode', + label: 'Merchant category', + type: 'select', + required: true, + order: 1 + } +] + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async function (knex) { + await knex.schema.createTable('field_definitions', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')) + + table.string('key').notNullable() + table.string('label').notNullable() + table.string('description').nullable() + table.enum('type', ['text', 'email', 'tel', 'number', 'select', 'checkbox', 'date']).notNullable() + table.boolean('required').notNullable().defaultTo(false) + table.string('placeholder').nullable() + table.integer('order').notNullable() + + table.integer('maxLength').nullable() + table.string('format').nullable() + + table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now()) + table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now()) + }) + + return knex('field_definitions').insert(fields) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.dropTableIfExists('field_definitions') +} \ No newline at end of file diff --git a/packages/wallet/backend/migrations/20260619142642_create_options.js b/packages/wallet/backend/migrations/20260619142642_create_options.js new file mode 100644 index 000000000..fccb03067 --- /dev/null +++ b/packages/wallet/backend/migrations/20260619142642_create_options.js @@ -0,0 +1,44 @@ +const options = [ + { + fieldId: "1acf7723-e1cd-44e7-a5db-3f614ce045ac", + value: "5411", + label: "Grocery stores / supermarkets" + }, + { + fieldId: "1acf7723-e1cd-44e7-a5db-3f614ce045ac", + value: "5812", + label: "Eating places / restaurants" + }, + { + fieldId: "1acf7723-e1cd-44e7-a5db-3f614ce045ac", + value: "5999", + label: "Miscellaneous retail" + } +] +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async function (knex) { + await knex.schema.createTable('options', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')) + + table.uuid('fieldId').notNullable() + table.foreign('fieldId').references('id').inTable('field_definitions') + table.string('value').notNullable() + table.string('label').notNullable() + + table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now()) + table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now()) + }) + + return knex('options').insert(options) +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.dropTableIfExists('options') +} \ No newline at end of file diff --git a/packages/wallet/backend/src/app.ts b/packages/wallet/backend/src/app.ts index e1265ed0a..b784f1a16 100644 --- a/packages/wallet/backend/src/app.ts +++ b/packages/wallet/backend/src/app.ts @@ -49,6 +49,7 @@ import { GateHubClient } from '@/gatehub/client' import { GateHubService } from '@/gatehub/service' import { CardController } from './card/controller' import { CardService } from './card/service' +import { TerminalController } from './terminal/controller' import { isRafikiSignedWebhook } from '@/middleware/isRafikiSignedWebhook' import { isGateHubSignedWebhook } from '@/middleware/isGateHubSignedWebhook' @@ -89,6 +90,7 @@ export interface Bindings { gateHubService: GateHubService cardService: CardService cardController: CardController + terminalController: TerminalController } export class App { @@ -164,6 +166,7 @@ export class App { const interledgerCardController = this.container.resolve( 'interledgerCardController' ) + const terminalController = this.container.resolve('terminalController') app.use( cors({ @@ -454,6 +457,9 @@ export class App { interledgerCardController.terminate ) + // Terminal + router.get('/terminals/onboarding', terminalController.getOnboardingFormDefinition) + // Return an error for invalid routes router.use('*', (req: Request, res: CustomResponse) => { const e = Error(`Requested path ${req.path} was not found`) diff --git a/packages/wallet/backend/src/createContainer.ts b/packages/wallet/backend/src/createContainer.ts index d59976385..bb2645013 100644 --- a/packages/wallet/backend/src/createContainer.ts +++ b/packages/wallet/backend/src/createContainer.ts @@ -57,6 +57,8 @@ import { StripeController } from './stripe-integration/controller' import { StripeService } from './stripe-integration/service' import { InterledgerCardController } from '@/interledgerCard/controller' import { InterledgerCardService } from '@/interledgerCard/service' +import { TerminalController } from '@/terminal/controller' +import { TerminalService } from '@/terminal/service' export interface Cradle { env: Env @@ -104,6 +106,8 @@ export interface Cradle { cardController: CardController interledgerCardService: InterledgerCardService interledgerCardController: InterledgerCardController + terminalService: TerminalService + terminalController: TerminalController } export async function createContainer( @@ -166,7 +170,9 @@ export async function createContainer( gateHubService: asClass(GateHubService).singleton(), cardController: asClass(CardController).singleton(), interledgerCardService: asClass(InterledgerCardService).singleton(), - interledgerCardController: asClass(InterledgerCardController).singleton() + interledgerCardController: asClass(InterledgerCardController).singleton(), + terminalService: asClassSingletonWithLogger(TerminalService, logger), + terminalController: asClass(TerminalController).singleton() }) return container diff --git a/packages/wallet/backend/src/terminal/controller.ts b/packages/wallet/backend/src/terminal/controller.ts new file mode 100644 index 000000000..fee50587f --- /dev/null +++ b/packages/wallet/backend/src/terminal/controller.ts @@ -0,0 +1,19 @@ +import { Request, Response, NextFunction } from 'express' +import { TerminalService } from './service' + +export class TerminalController { + constructor(private terminalService: TerminalService) {} + + getOnboardingFormDefinition = async ( + _req: Request, + res: Response, + next: NextFunction + ) => { + try { + const formDefinition = await this.terminalService.getOnboardingFormDefinition() + res.status(200).json(formDefinition) + } catch (error) { + next(error) + } + } +} diff --git a/packages/wallet/backend/src/terminal/model.ts b/packages/wallet/backend/src/terminal/model.ts new file mode 100644 index 000000000..b87adf961 --- /dev/null +++ b/packages/wallet/backend/src/terminal/model.ts @@ -0,0 +1,57 @@ +import { BaseModel } from '@shared/backend' +import { Model } from 'objection' + +interface Validation { + minLength?: number + maxLength?: number + pattern?: string + min?: number + max?: number + format?: string + mustEqual?: boolean +} + +enum FieldType { + TEXT = 'text', + EMAIL = 'email', + TEL = 'tel', + NUMBER = 'number', + SELECT = 'select', + CHECKBOX = 'checkbox', + DATE = 'date' +} + +export class FieldDefinitions extends BaseModel { + static tableName = 'field_definitions' + + public key!: string + public label!: string + public description?: string + public type!: FieldType + public required!: boolean + public placeholder?: string + public order!: number + public options?: Options[] + public validation?: Validation + public format?: string + public maxLength?: number + + static relationMappings = () => ({ + options: { + relation: Model.HasManyRelation, + modelClass: Options, + join: { + from: 'field_definitions.id', + to: 'options.fieldId' + } + } + }) +} + +export class Options extends BaseModel { + static tableName = 'options' + + public fieldId?: string + public value!: string + public label!: string +} \ No newline at end of file diff --git a/packages/wallet/backend/src/terminal/service.ts b/packages/wallet/backend/src/terminal/service.ts new file mode 100644 index 000000000..407b79f6b --- /dev/null +++ b/packages/wallet/backend/src/terminal/service.ts @@ -0,0 +1,41 @@ +import { Logger } from 'winston' +import { FieldDefinitions } from './model'; + +export class TerminalService { + constructor(private logger: Logger) {} + + async getOnboardingFormDefinition(): Promise { + const fields = await FieldDefinitions.query() + .withGraphFetched('options') + .orderBy('order', 'asc') + + this.logger.debug('Returning merchant onboarding form definition', { + fields: fields + }) + + return fields.map(field => { + const mapped = { + key: field.key, + label: field.label, + type: field.type, + required: field.required, + order: field.order, + } as Partial + + if (field.description) mapped.description = field.description + if (field.placeholder) mapped.placeholder = field.placeholder + if(field.format) + mapped.validation = {...(mapped.validation || {}), format: field.format} + if(field.maxLength) + mapped.validation = {...(mapped.validation || {}), maxLength: field.maxLength} + if (field.options?.length) { + mapped.options = field.options.map(opt => ({ + ...(opt.value && { value: opt.value }), + ...(opt.label && { label: opt.label }) + })) as FieldDefinitions['options'] + } + + return mapped as FieldDefinitions + }) + } +} \ No newline at end of file From 4987cba75b00225330596fa41c70d9e6388091ef Mon Sep 17 00:00:00 2001 From: mirunagherman Date: Mon, 22 Jun 2026 15:52:13 +0300 Subject: [PATCH 2/2] style: prettier formatting --- ...20260618154710_create_field_definitions.js | 18 +++++++++--- .../20260619142642_create_options.js | 26 ++++++++--------- packages/wallet/backend/src/app.ts | 5 +++- .../wallet/backend/src/terminal/controller.ts | 3 +- packages/wallet/backend/src/terminal/model.ts | 2 +- .../wallet/backend/src/terminal/service.ts | 28 +++++++++++-------- 6 files changed, 51 insertions(+), 31 deletions(-) diff --git a/packages/wallet/backend/migrations/20260618154710_create_field_definitions.js b/packages/wallet/backend/migrations/20260618154710_create_field_definitions.js index fb6e67170..52000ecc0 100644 --- a/packages/wallet/backend/migrations/20260618154710_create_field_definitions.js +++ b/packages/wallet/backend/migrations/20260618154710_create_field_definitions.js @@ -10,7 +10,7 @@ const fields = [ format: 'email', maxLength: 255 }, - { + { id: '1acf7723-e1cd-44e7-a5db-3f614ce045ac', key: 'merchantCategoryCode', label: 'Merchant category', @@ -31,14 +31,24 @@ exports.up = async function (knex) { table.string('key').notNullable() table.string('label').notNullable() table.string('description').nullable() - table.enum('type', ['text', 'email', 'tel', 'number', 'select', 'checkbox', 'date']).notNullable() + table + .enum('type', [ + 'text', + 'email', + 'tel', + 'number', + 'select', + 'checkbox', + 'date' + ]) + .notNullable() table.boolean('required').notNullable().defaultTo(false) table.string('placeholder').nullable() table.integer('order').notNullable() table.integer('maxLength').nullable() table.string('format').nullable() - + table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now()) table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now()) }) @@ -52,4 +62,4 @@ exports.up = async function (knex) { */ exports.down = function (knex) { return knex.schema.dropTableIfExists('field_definitions') -} \ No newline at end of file +} diff --git a/packages/wallet/backend/migrations/20260619142642_create_options.js b/packages/wallet/backend/migrations/20260619142642_create_options.js index fccb03067..b066ff3d6 100644 --- a/packages/wallet/backend/migrations/20260619142642_create_options.js +++ b/packages/wallet/backend/migrations/20260619142642_create_options.js @@ -1,18 +1,18 @@ const options = [ - { - fieldId: "1acf7723-e1cd-44e7-a5db-3f614ce045ac", - value: "5411", - label: "Grocery stores / supermarkets" + { + fieldId: '1acf7723-e1cd-44e7-a5db-3f614ce045ac', + value: '5411', + label: 'Grocery stores / supermarkets' }, - { - fieldId: "1acf7723-e1cd-44e7-a5db-3f614ce045ac", - value: "5812", - label: "Eating places / restaurants" + { + fieldId: '1acf7723-e1cd-44e7-a5db-3f614ce045ac', + value: '5812', + label: 'Eating places / restaurants' }, - { - fieldId: "1acf7723-e1cd-44e7-a5db-3f614ce045ac", - value: "5999", - label: "Miscellaneous retail" + { + fieldId: '1acf7723-e1cd-44e7-a5db-3f614ce045ac', + value: '5999', + label: 'Miscellaneous retail' } ] /** @@ -41,4 +41,4 @@ exports.up = async function (knex) { */ exports.down = function (knex) { return knex.schema.dropTableIfExists('options') -} \ No newline at end of file +} diff --git a/packages/wallet/backend/src/app.ts b/packages/wallet/backend/src/app.ts index 4f54df3ce..4c2f44335 100644 --- a/packages/wallet/backend/src/app.ts +++ b/packages/wallet/backend/src/app.ts @@ -458,7 +458,10 @@ export class App { ) // Terminal - router.get('/terminals/onboarding', terminalController.getOnboardingFormDefinition) + router.get( + '/terminals/onboarding', + terminalController.getOnboardingFormDefinition + ) // Return an error for invalid routes router.use('*', (req: Request, res: CustomResponse) => { diff --git a/packages/wallet/backend/src/terminal/controller.ts b/packages/wallet/backend/src/terminal/controller.ts index fee50587f..6371f6f57 100644 --- a/packages/wallet/backend/src/terminal/controller.ts +++ b/packages/wallet/backend/src/terminal/controller.ts @@ -10,7 +10,8 @@ export class TerminalController { next: NextFunction ) => { try { - const formDefinition = await this.terminalService.getOnboardingFormDefinition() + const formDefinition = + await this.terminalService.getOnboardingFormDefinition() res.status(200).json(formDefinition) } catch (error) { next(error) diff --git a/packages/wallet/backend/src/terminal/model.ts b/packages/wallet/backend/src/terminal/model.ts index b87adf961..ec30d2a9e 100644 --- a/packages/wallet/backend/src/terminal/model.ts +++ b/packages/wallet/backend/src/terminal/model.ts @@ -54,4 +54,4 @@ export class Options extends BaseModel { public fieldId?: string public value!: string public label!: string -} \ No newline at end of file +} diff --git a/packages/wallet/backend/src/terminal/service.ts b/packages/wallet/backend/src/terminal/service.ts index 407b79f6b..c5bf577a6 100644 --- a/packages/wallet/backend/src/terminal/service.ts +++ b/packages/wallet/backend/src/terminal/service.ts @@ -1,5 +1,5 @@ import { Logger } from 'winston' -import { FieldDefinitions } from './model'; +import { FieldDefinitions } from './model' export class TerminalService { constructor(private logger: Logger) {} @@ -13,29 +13,35 @@ export class TerminalService { fields: fields }) - return fields.map(field => { + return fields.map((field) => { const mapped = { key: field.key, label: field.label, type: field.type, required: field.required, - order: field.order, + order: field.order } as Partial - + if (field.description) mapped.description = field.description if (field.placeholder) mapped.placeholder = field.placeholder - if(field.format) - mapped.validation = {...(mapped.validation || {}), format: field.format} - if(field.maxLength) - mapped.validation = {...(mapped.validation || {}), maxLength: field.maxLength} + if (field.format) + mapped.validation = { + ...(mapped.validation || {}), + format: field.format + } + if (field.maxLength) + mapped.validation = { + ...(mapped.validation || {}), + maxLength: field.maxLength + } if (field.options?.length) { - mapped.options = field.options.map(opt => ({ + mapped.options = field.options.map((opt) => ({ ...(opt.value && { value: opt.value }), ...(opt.label && { label: opt.label }) })) as FieldDefinitions['options'] } - + return mapped as FieldDefinitions }) } -} \ No newline at end of file +}