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..52000ecc0 --- /dev/null +++ b/packages/wallet/backend/migrations/20260618154710_create_field_definitions.js @@ -0,0 +1,65 @@ +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') +} 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..b066ff3d6 --- /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') +} diff --git a/packages/wallet/backend/src/app.ts b/packages/wallet/backend/src/app.ts index d7bcb9ac9..4c2f44335 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,12 @@ 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..6371f6f57 --- /dev/null +++ b/packages/wallet/backend/src/terminal/controller.ts @@ -0,0 +1,20 @@ +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..ec30d2a9e --- /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 +} diff --git a/packages/wallet/backend/src/terminal/service.ts b/packages/wallet/backend/src/terminal/service.ts new file mode 100644 index 000000000..c5bf577a6 --- /dev/null +++ b/packages/wallet/backend/src/terminal/service.ts @@ -0,0 +1,47 @@ +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 + }) + } +}