diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml index badbe2f..237fb09 100644 --- a/.github/workflows/server.yml +++ b/.github/workflows/server.yml @@ -37,5 +37,15 @@ jobs: - name: Run Lint run: composer lint - # - name: Run Tests -- Will add tests back after phppest issue https://github.com/pestphp/pest/issues/920 is fixed - # run: composer test:unit + - name: Run Tests + run: composer test:unit + + - name: Generate Coverage Baseline + run: XDEBUG_MODE=coverage composer coverage:baseline + + - name: Upload Coverage Baseline + uses: actions/upload-artifact@v4 + with: + name: ledger-coverage-clover + path: coverage/clover.xml + if-no-files-found: error diff --git a/addon/components/gateway/catalog-card.hbs b/addon/components/gateway/catalog-card.hbs new file mode 100644 index 0000000..c05d09a --- /dev/null +++ b/addon/components/gateway/catalog-card.hbs @@ -0,0 +1,34 @@ + diff --git a/addon/components/gateway/catalog-card.js b/addon/components/gateway/catalog-card.js new file mode 100644 index 0000000..dd84e8c --- /dev/null +++ b/addon/components/gateway/catalog-card.js @@ -0,0 +1,3 @@ +import Component from '@glimmer/component'; + +export default class GatewayCatalogCardComponent extends Component {} diff --git a/addon/components/gateway/details.hbs b/addon/components/gateway/details.hbs index 3874e7f..a9c4379 100644 --- a/addon/components/gateway/details.hbs +++ b/addon/components/gateway/details.hbs @@ -1,114 +1,276 @@ -
- {{! GATEWAY DETAILS }} - -
+
+ {{#if this.showOverview}} +
+ {{#each this.readinessItems as |item|}} + + {{/each}} +
-
- Gateway Information -
- -
-
Name
-
{{n-a @resource.name}}
-
- -
-
Code
-
{{n-a @resource.code}}
-
- -
-
Driver
-
{{n-a (titleize @resource.driver)}}
-
+
+
+
+
+

Recent Gateway Activity

+

Latest webhook, payment, refund, and settlement records for this gateway.

+
+
+ {{#each this.recentActivity as |activity|}} +
+
+ + {{activity.label}} +
+
+
{{n-a activity.message}}
+ {{#if activity.reference}} +
{{activity.reference}}
+ {{/if}} +
+
+ {{#if activity.date}} + {{format-date-fns activity.date "MMM d, HH:mm"}} + {{else}} + No timestamp + {{/if}} +
+
+ {{else}} +
No gateway activity has been recorded yet.
+ {{/each}} +
+
-
-
Type
-
{{n-a @resource.type}}
+
+
+

Actions

+

Provider operations an operator is likely to need next.

+
+
+ {{#each this.overviewActions as |gatewayAction|}} +
+
+
{{gatewayAction.title}}
+

{{gatewayAction.description}}

+ {{#if (eq gatewayAction.key "copy-webhook-url")}} + {{#if @resource.system_webhook_url}} + + + + {{@resource.system_webhook_url}} + + + {{else}} +
Webhook URL unavailable until this gateway has a driver.
+ {{/if}} + {{/if}} +
+
+ {{/each}} +
+
-
-
Environment
-
- {{n-a @resource.environment}} +
+
+ {{#each this.providerStatusRows as |row|}} +
+
+ +
+
+
+ {{row.label}} + {{#if row.status}} + {{titleize row.value}} + {{else}} + {{n-a row.value}} + {{/if}} +
+ {{#if row.date}} +
{{format-date-fns row.date "MMM d, HH:mm"}}
+ {{/if}} + {{#if row.caption}} +

{{row.caption}}

+ {{/if}} +
+
+ {{/each}} +
+ +
+ {{/if}} -
-
Status
-
- {{titleize @resource.status}} + {{#if this.showSetup}} +
+
+
+

Gateway Setup

+

Configuration Ledger uses for routing, provider setup, and checkout availability.

+
- {{#if @resource.description}} -
-
Description
-
{{@resource.description}}
+
+
+

Identity

+
+ {{#each this.setupIdentityRows as |row|}} +
+ {{row.label}} + + {{#if row.date}} + {{#if row.value}}{{format-date-fns row.value "MMM d, yyyy HH:mm"}}{{else}}N/A{{/if}} + {{else if row.copyable}} + {{#if row.value}} + + + + {{or row.displayValue row.value}} + + + {{else}} + N/A + {{/if}} + {{else}} + {{n-a row.value}} + {{/if}} + +
+ {{/each}} +
- {{/if}} - -
- - - {{! INTEGRATION }} - -
- {{! System-generated webhook URL — always shown, full absolute URL from backend }} - {{#if @resource.system_webhook_url}} -
-
Webhook URL
-
- {{@resource.system_webhook_url}} - -
-
- Register this URL in your payment gateway dashboard to receive webhook events. +
+

Routing

+
+ {{#each this.setupRoutingRows as |row|}} +
+ {{row.label}} + + {{#if row.copyable}} + {{#if row.value}} + + + + {{or row.displayValue row.value}} + + + {{#if row.caption}} +

{{row.caption}}

+ {{/if}} + {{else}} + N/A + {{/if}} + {{else}} + {{n-a row.value}} + {{/if}} +
+
+ {{/each}}
- {{else if @resource.webhook_url}} - {{! Fallback: show the stored webhook_url if no system URL is available }} -
-
Webhook URL
-
{{@resource.webhook_url}}
-
- {{/if}} - {{#if @resource.capabilities}} -
-
Capabilities
-
- {{#each @resource.capabilities as |cap|}} - {{cap}} +
+

Provider Configuration

+
+ {{#each this.setupConfigRows as |row|}} +
+ {{row.label}} + {{n-a row.value}} +
{{/each}}
- {{/if}} - -
- - - {{! RECORD INFO }} - -
- -
-
Internal ID
-
{{n-a @resource.public_id}}
+
+ {{/if}} -
-
Created
-
{{n-a @resource.createdAt}}
+ {{#if this.showDiagnostics}} +
+
+
+

Provider Tools

+

Run provider-facing checks and provisioning actions with a clear expected result.

+
+
-
-
Last Updated
-
{{n-a @resource.updatedAt}}
-
+ {{#if this.diagnosticActions.length}} +
+
+ {{#each this.diagnosticActions as |diagnosticAction|}} + {{#unless diagnosticAction.secondary}} +
+
+
+ +

{{diagnosticAction.title}}

+
+

{{diagnosticAction.description}}

+

Expected result: {{diagnosticAction.expectedResult}}

+
+
+ {{/unless}} + {{/each}} +
+ +
- - -
\ No newline at end of file + {{#if this.lastActionResult}} +
+
+ {{titleize this.lastActionResult.status}} + {{this.lastActionResult.message}} +
+ {{#if this.lastActionResult.data.taler_pay_uri}} +
{{this.lastActionResult.data.taler_pay_uri}}
+ {{/if}} +
+ {{/if}} + +
+ {{else}} +
This gateway driver does not expose provider diagnostics.
+ {{/if}} + + {{/if}} +
diff --git a/addon/components/gateway/details.js b/addon/components/gateway/details.js new file mode 100644 index 0000000..5ce7b52 --- /dev/null +++ b/addon/components/gateway/details.js @@ -0,0 +1,517 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { task } from 'ember-concurrency'; +import copyToClipboard from '@fleetbase/ember-core/utils/copy-to-clipboard'; + +export default class GatewayDetailsComponent extends Component { + @service fetch; + @service notifications; + @service modalsManager; + @service hostRouter; + + @tracked diagnostics = null; + @tracked lastActionResult = null; + + constructor() { + super(...arguments); + this.loadDiagnostics.perform(); + } + + get isTaler() { + return this.args.resource?.driver === 'taler'; + } + + get gatewayId() { + return this.args.resource?.id; + } + + get section() { + return this.args.section ?? 'overview'; + } + + get showOverview() { + return this.section === 'overview'; + } + + get showSetup() { + return this.section === 'setup'; + } + + get showDiagnostics() { + return this.section === 'diagnostics'; + } + + get driverLabel() { + const driver = this.args.resource?.driver; + + if (!driver) { + return 'Gateway'; + } + + if (driver === 'taler') { + return 'GNU Taler'; + } + + if (driver === 'qpay') { + return 'QPay'; + } + + return driver.charAt(0).toUpperCase() + driver.slice(1); + } + + get diagnosticSummary() { + return this.diagnostics?.diagnostics ?? {}; + } + + get gatewaySummary() { + return this.diagnostics?.gateway ?? {}; + } + + get webhookStatus() { + return this.diagnosticSummary.webhook_registration ?? (this.args.resource?.webhook_url ? 'configured' : 'not_configured'); + } + + get lastWebhookAt() { + return this.diagnosticSummary.last_webhook_received_at; + } + + get lastPaymentAt() { + return this.diagnosticSummary.last_payment_event_at; + } + + get lastRefundAt() { + return this.diagnosticSummary.last_refund_event_at; + } + + get lastSettlementAt() { + return this.diagnosticSummary.last_settlement_seen_at; + } + + get reconciliationStatus() { + return this.diagnosticSummary.last_reconciliation_status; + } + + get credentialStatus() { + return this.diagnosticSummary.credential_status ?? 'not_checked'; + } + + get lastCredentialTestedAt() { + return this.diagnosticSummary.last_credential_tested_at; + } + + get lastCredentialTestMessage() { + return this.diagnosticSummary.last_credential_test_message; + } + + get lastWebhookRegistrationAt() { + return this.diagnosticSummary.last_webhook_registration_at; + } + + get lastTestOrderAt() { + return this.diagnosticSummary.last_test_order_at; + } + + get lastTestOrderId() { + return this.diagnosticSummary.last_test_order_id; + } + + get credentialTileValue() { + if (this.credentialStatus === 'success' || this.credentialStatus === 'ok') { + return 'Verified'; + } + + if (this.credentialStatus === 'failed') { + return 'Failed'; + } + + return 'Not Checked'; + } + + get credentialAccentClass() { + if (this.credentialStatus === 'success' || this.credentialStatus === 'ok') { + return 'ledger-gateway-kpi-accent-green'; + } + + if (this.credentialStatus === 'failed') { + return 'ledger-gateway-kpi-accent-rose'; + } + + return 'ledger-gateway-kpi-accent-slate'; + } + + get webhookRegistrationResultMessage() { + if (this.lastWebhookRegistrationAt) { + return 'Provider webhook registration completed.'; + } + + if (this.webhookStatus === 'configured') { + return 'Webhook URL is configured, but no registration action has been recorded.'; + } + + return 'No provider webhook registration stored.'; + } + + get readinessItems() { + return [ + { + label: 'Connection', + value: this.args.resource?.status === 'active' ? 'Ready' : 'Inactive', + status: this.args.resource?.status === 'active' ? 'ready' : 'inactive', + icon: 'plug', + caption: this.args.resource?.status === 'active' ? 'Available on invoices' : 'Not used at checkout', + accentClass: this.args.resource?.status === 'active' ? 'ledger-gateway-kpi-accent-green' : 'ledger-gateway-kpi-accent-slate', + }, + { + label: 'Environment', + value: this.args.resource?.environment === 'live' ? 'Live' : 'Sandbox', + status: this.args.resource?.environment ?? 'unknown', + icon: 'server', + caption: this.args.resource?.environment === 'live' ? 'Production payments' : 'Testing and sandbox use', + accentClass: this.args.resource?.environment === 'live' ? 'ledger-gateway-kpi-accent-blue' : 'ledger-gateway-kpi-accent-amber', + }, + { + label: 'Webhook', + value: this.webhookStatus === 'configured' ? 'Configured' : 'Needs Setup', + status: this.webhookStatus === 'configured' ? 'configured' : 'needs setup', + icon: 'link', + caption: this.webhookStatus === 'configured' ? 'Provider callback ready' : 'Registration needed', + accentClass: this.webhookStatus === 'configured' ? 'ledger-gateway-kpi-accent-green' : 'ledger-gateway-kpi-accent-amber', + }, + { + label: 'Credentials', + value: this.credentialTileValue, + status: this.credentialStatus, + icon: 'key', + caption: this.lastCredentialTestedAt ? 'Last provider auth test' : 'Run a provider check', + accentClass: this.credentialAccentClass, + }, + ]; + } + + get providerStatusRows() { + return [ + { + label: 'Driver', + value: this.driverLabel, + icon: this.isTaler ? 'wallet' : 'plug', + }, + { + label: 'Mode', + value: this.args.resource?.environment === 'live' ? 'Live' : 'Sandbox', + icon: this.args.resource?.environment === 'live' ? 'bolt' : 'flask', + }, + { + label: 'Credentials', + value: this.credentialStatus === 'not_checked' ? 'Not checked' : this.credentialStatus, + caption: this.lastCredentialTestMessage, + date: this.lastCredentialTestedAt, + icon: 'key', + status: this.credentialStatus === 'success' || this.credentialStatus === 'ok' ? 'success' : this.credentialStatus === 'failed' ? 'danger' : 'warning', + }, + { + label: 'Webhook', + value: this.webhookStatus === 'configured' ? 'Configured' : 'Needs setup', + date: this.lastWebhookRegistrationAt ?? this.lastWebhookAt, + icon: 'link', + status: this.webhookStatus === 'configured' ? 'success' : 'warning', + }, + { + label: 'Payment', + value: this.lastPaymentAt ? 'Seen' : 'No payment yet', + date: this.lastPaymentAt, + icon: 'money-bill-transfer', + status: this.lastPaymentAt ? 'success' : 'default', + }, + { + label: 'Refund', + value: this.lastRefundAt ? 'Seen' : 'No refund yet', + date: this.lastRefundAt, + icon: 'rotate-left', + status: this.lastRefundAt ? 'success' : 'default', + }, + { + label: 'Settlement', + value: this.reconciliationStatus ?? 'Not checked', + date: this.lastSettlementAt, + icon: 'scale-balanced', + status: this.reconciliationStatus ? 'success' : 'warning', + }, + ]; + } + + get setupIdentityRows() { + return [ + { + label: 'Name', + value: this.args.resource?.name, + }, + { + label: 'Driver', + value: this.driverLabel, + }, + { + label: 'Public ID', + value: this.args.resource?.public_id, + mono: true, + copyable: true, + }, + { + label: 'Status', + value: this.args.resource?.status, + }, + { + label: 'Environment', + value: this.args.resource?.environment ?? (this.args.resource?.is_sandbox ? 'sandbox' : 'live'), + }, + { + label: 'Created', + value: this.args.resource?.createdAt ?? this.args.resource?.created_at, + date: true, + }, + ]; + } + + get setupRoutingRows() { + return [ + { + label: 'Return URL', + value: this.args.resource?.return_url, + mono: true, + copyable: true, + }, + { + label: 'Ledger webhook URL', + value: this.args.resource?.system_webhook_url, + mono: true, + copyable: true, + }, + { + label: 'Provider registered callback', + value: this.args.resource?.webhook_url, + displayValue: + this.args.resource?.webhook_url && this.args.resource?.webhook_url === this.args.resource?.system_webhook_url + ? 'Same as Ledger webhook URL' + : this.args.resource?.webhook_url, + caption: + this.args.resource?.webhook_url && this.args.resource?.webhook_url === this.args.resource?.system_webhook_url + ? 'The provider posts events to the Ledger webhook URL above.' + : null, + mono: true, + copyable: true, + }, + ]; + } + + get setupConfigRows() { + const configSummary = this.gatewaySummary.config_summary ?? []; + + if (configSummary.length) { + return configSummary; + } + + return [ + { + label: 'Provider credentials', + value: 'Stored encrypted and hidden', + }, + ]; + } + + get recentActivity() { + return [ + this.activityItem('Webhook', this.diagnostics?.last_webhook, 'link'), + this.activityItem('Payment', this.diagnostics?.last_payment, 'money-bill-transfer'), + this.activityItem('Refund', this.diagnostics?.last_refund, 'rotate-left'), + this.activityItem('Settlement', this.diagnostics?.last_settlement, 'scale-balanced', this.lastSettlementAt), + ].filter(Boolean); + } + + get diagnosticsResults() { + return [ + { + label: 'Credential test', + value: this.lastCredentialTestMessage ?? 'No credential test has been run.', + date: this.lastCredentialTestedAt, + status: this.credentialStatus === 'success' || this.credentialStatus === 'ok' ? 'success' : this.credentialStatus === 'failed' ? 'danger' : 'warning', + }, + { + label: 'Webhook registration', + value: this.webhookRegistrationResultMessage, + date: this.lastWebhookRegistrationAt, + status: this.lastWebhookRegistrationAt ? 'success' : 'warning', + }, + { + label: 'Test order', + value: this.lastTestOrderId ? `Last order ${this.lastTestOrderId}` : 'No test order has been created.', + date: this.lastTestOrderAt, + status: this.lastTestOrderId ? 'success' : 'warning', + }, + ]; + } + + get diagnosticActions() { + if (!this.isTaler) { + return []; + } + + return [ + { + key: 'test-credentials', + title: 'Test Credentials', + description: 'Checks whether Ledger can authenticate against the configured Taler Merchant Backend.', + expectedResult: 'Updates credential status and last checked time.', + icon: this.testCredentials.isRunning ? 'spinner' : 'check-circle', + isLoading: this.testCredentials.isRunning, + task: this.testCredentials, + primary: true, + }, + { + key: 'register-webhook', + title: 'Register Webhook', + description: 'Creates or updates the Taler webhook so payment events route back to this exact gateway.', + expectedResult: 'Stores the registered webhook URL and routing payload.', + icon: this.registerWebhook.isRunning ? 'spinner' : 'link', + isLoading: this.registerWebhook.isRunning, + task: this.registerWebhook, + confirm: true, + confirmTitle: 'Register Taler webhook?', + confirmBody: 'Ledger will create or update the webhook in the Taler Merchant Backend for this gateway.', + }, + { + key: 'create-test-order', + title: 'Create Test Order', + description: 'Creates a small sandbox order to verify order creation and wallet redirect behavior.', + expectedResult: 'Creates a provider test order and records its order id.', + icon: this.createTestOrder.isRunning ? 'spinner' : 'flask', + isLoading: this.createTestOrder.isRunning, + task: this.createTestOrder, + confirm: true, + confirmTitle: 'Create Taler test order?', + confirmBody: 'Ledger will create a test order in the configured Taler Merchant Backend. No customer invoice is changed.', + }, + { + key: 'refresh', + title: 'Refresh Diagnostics', + description: 'Reloads the latest credential, webhook, payment, refund, and settlement diagnostic data from Ledger.', + expectedResult: 'No provider mutation; refreshes this screen only.', + icon: this.loadDiagnostics.isRunning ? 'spinner' : 'rotate', + isLoading: this.loadDiagnostics.isRunning, + task: this.loadDiagnostics, + secondary: true, + }, + ]; + } + + get overviewActions() { + return [ + ...this.diagnosticActions.filter((diagnosticAction) => diagnosticAction.key !== 'refresh'), + { + key: 'copy-webhook-url', + title: 'Copy Webhook URL', + description: 'Copies the Ledger callback URL for manual provider setup.', + expectedResult: 'Use this when provider webhook registration is handled outside Ledger.', + icon: 'copy', + action: this.copySystemWebhookUrl, + secondary: true, + }, + { + key: 'edit-settings', + title: 'Edit Settings', + description: 'Change the gateway name, credentials, routing, status, or environment.', + expectedResult: 'Opens the gateway setup wizard for this existing gateway.', + icon: 'pen-to-square', + action: this.editGateway, + secondary: true, + }, + ]; + } + + activityItem(label, record, icon, fallbackDate) { + if (!record && !fallbackDate) { + return null; + } + + return { + label, + icon, + status: record?.status ?? record?.event_type ?? this.reconciliationStatus ?? 'seen', + message: record?.message ?? record?.description ?? (label === 'Settlement' ? this.reconciliationStatus : null), + date: record?.created_at ?? record?.createdAt ?? fallbackDate, + reference: record?.gateway_reference_id ?? record?.id, + }; + } + + @task({ restartable: true }) + *loadDiagnostics() { + if (!this.gatewayId) return; + + try { + this.diagnostics = yield this.fetch.get(`gateways/${this.gatewayId}/diagnostics`, {}, { namespace: 'ledger/int/v1' }); + } catch { + this.diagnostics = null; + } + } + + @task({ drop: true }) + *testCredentials() { + yield* this.runGatewayAction('test-credentials', 'Taler credentials accepted.'); + } + + @task({ drop: true }) + *createTestOrder() { + yield* this.runGatewayAction('create-test-order', 'Taler test order created.'); + } + + @task({ drop: true }) + *registerWebhook() { + yield* this.runGatewayAction('register-webhook', 'Taler webhook registered.'); + } + + @action runDiagnosticAction(diagnosticAction) { + if (diagnosticAction.action) { + return diagnosticAction.action(); + } + + if (diagnosticAction.confirm) { + return this.modalsManager.confirm({ + title: diagnosticAction.confirmTitle, + body: diagnosticAction.confirmBody, + acceptButtonText: diagnosticAction.title, + confirm: () => diagnosticAction.task.perform(), + }); + } + + return diagnosticAction.task.perform(); + } + + @action editGateway() { + return this.hostRouter.transitionTo('console.ledger.payments.gateways.edit', this.args.resource); + } + + @action copySystemWebhookUrl() { + const url = this.args.resource?.system_webhook_url; + + if (!url) { + return this.notifications.warning('No system webhook URL is available yet.'); + } + + copyToClipboard(url) + .then(() => this.notifications.success('System webhook URL copied.')) + .catch(() => this.notifications.error('Unable to copy webhook URL.')); + } + + *runGatewayAction(action, successMessage) { + if (!this.gatewayId) return; + + try { + const result = yield this.fetch.post(`gateways/${this.gatewayId}/${action}`, {}, { namespace: 'ledger/int/v1' }); + this.lastActionResult = result; + this.notifications.success(result?.message ?? successMessage); + yield this.loadDiagnostics.perform(); + } catch (error) { + this.notifications.serverError(error); + } + } +} diff --git a/addon/components/gateway/form.hbs b/addon/components/gateway/form.hbs index d249b81..533e957 100644 --- a/addon/components/gateway/form.hbs +++ b/addon/components/gateway/form.hbs @@ -1,179 +1,310 @@ -
- {{! GATEWAY DETAILS }} - -
- - {{! Identification }} -
- Identification -
- - - - - - - - - - -
- -
-
{{driver.name}}
-
{{driver.description}}
-
-
+
+
+
+
+
+ {{#each this.setupSteps as |step index|}} + + {{/each}}
- - - {{! If a driver is selected, show its capabilities }} - {{#if @resource.driver}} - {{#let (find-by "code" @resource.driver this.availableDrivers) as |driver|}} - {{#if driver.capabilities}} -
- {{#each driver.capabilities as |cap|}} - {{cap}} +
+ + {{#if (eq this.currentStep "gateway")}} +
+
+

Choose Payment Gateway

+

Select the provider Ledger should use for invoice checkout, payment confirmation, refunds, and settlement tracking.

+
+ + {{#if this.loadDrivers.isRunning}} +
+ {{else}} +
+ {{#each this.driverCards as |driver|}} + {{/each}}
{{/if}} - {{/let}} - {{/if}} +
+ {{else if (eq this.currentStep "credentials")}} +
+
+
+

Gateway Credentials

+

Enter the provider configuration Ledger stores on this gateway. Tokens are saved on the gateway record and are never shown in invoice checkout.

+
- {{! Environment & Settings }} -
- Environment & Settings -
- - -
- -
{{option}}
-
-
-
- - -
- -
{{option}}
-
-
-
- - -