mirror of
https://github.com/getAlby/hub.git
synced 2026-08-13 12:33:39 +02:00
feat: receive invoices to apps (#2466)
* feat: receive invoices to apps * fix: handle cleared receive selector
This commit is contained in:
parent
417cb16d97
commit
816e0dda6e
9 changed files with 217 additions and 5 deletions
|
|
@ -46,7 +46,7 @@ type API interface {
|
|||
ListTransactions(ctx context.Context, appId *uint, limit uint64, offset uint64) (*ListTransactionsResponse, error)
|
||||
ListOnchainTransactions(ctx context.Context) ([]OnchainTransaction, error)
|
||||
SendPayment(ctx context.Context, invoice string, amountMsat *uint64, metadata map[string]interface{}, fromAppId *uint) (*SendPaymentResponse, error)
|
||||
CreateInvoice(ctx context.Context, amountMsat uint64, description string) (*MakeInvoiceResponse, error)
|
||||
CreateInvoice(ctx context.Context, amountMsat uint64, description string, toAppId *uint) (*MakeInvoiceResponse, error)
|
||||
LookupInvoice(ctx context.Context, paymentHash string) (*LookupInvoiceResponse, error)
|
||||
SetTransactionUserLabels(ctx context.Context, id uint, labels map[string]string) error
|
||||
RequestMempoolApi(ctx context.Context, endpoint string) (interface{}, error)
|
||||
|
|
@ -586,6 +586,7 @@ type MakeInvoiceRequest struct {
|
|||
AmountSat *uint64 `json:"amountSat"`
|
||||
AmountMsat *uint64 `json:"amountMsat"`
|
||||
Description string `json:"description"`
|
||||
ToAppID *uint `json:"toAppId"`
|
||||
}
|
||||
|
||||
type ResetRouterRequest struct {
|
||||
|
|
|
|||
|
|
@ -12,12 +12,17 @@ import (
|
|||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func (api *api) CreateInvoice(ctx context.Context, amountMsat uint64, description string) (*MakeInvoiceResponse, error) {
|
||||
func (api *api) CreateInvoice(ctx context.Context, amountMsat uint64, description string, toAppId *uint) (*MakeInvoiceResponse, error) {
|
||||
lnClient := api.svc.GetLNClient()
|
||||
if lnClient == nil {
|
||||
return nil, ErrLNClientNotStarted
|
||||
}
|
||||
transaction, err := api.svc.GetTransactionsService().MakeInvoice(ctx, amountMsat, description, "", 0, nil, lnClient, nil, nil, nil)
|
||||
|
||||
if toAppId != nil && api.appsSvc.GetAppById(*toAppId) == nil {
|
||||
return nil, errors.New("app does not exist")
|
||||
}
|
||||
|
||||
transaction, err := api.svc.GetTransactionsService().MakeInvoice(ctx, amountMsat, description, "", 0, nil, lnClient, toAppId, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
62
api/transactions_test.go
Normal file
62
api/transactions_test.go
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/getAlby/hub/tests"
|
||||
"github.com/getAlby/hub/tests/mocks"
|
||||
"github.com/getAlby/hub/transactions"
|
||||
)
|
||||
|
||||
func TestCreateInvoice_ToApp(t *testing.T) {
|
||||
ctx := context.TODO()
|
||||
|
||||
testSvc, err := tests.CreateTestService(t)
|
||||
require.NoError(t, err)
|
||||
defer testSvc.Remove()
|
||||
|
||||
app, _, err := tests.CreateApp(testSvc)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := mocks.NewMockService(t)
|
||||
svc.On("GetLNClient").Return(testSvc.LNClient)
|
||||
svc.On("GetTransactionsService").Return(transactions.NewTransactionsService(testSvc.DB, testSvc.EventPublisher))
|
||||
|
||||
theAPI := &api{
|
||||
appsSvc: testSvc.AppsService,
|
||||
svc: svc,
|
||||
}
|
||||
|
||||
transaction, err := theAPI.CreateInvoice(ctx, 1000, "Hello world", &app.ID)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, transaction.AppId)
|
||||
assert.Equal(t, app.ID, *transaction.AppId)
|
||||
}
|
||||
|
||||
func TestCreateInvoice_ToAppNotFound(t *testing.T) {
|
||||
ctx := context.TODO()
|
||||
|
||||
testSvc, err := tests.CreateTestService(t)
|
||||
require.NoError(t, err)
|
||||
defer testSvc.Remove()
|
||||
|
||||
svc := mocks.NewMockService(t)
|
||||
svc.On("GetLNClient").Return(testSvc.LNClient)
|
||||
|
||||
theAPI := &api{
|
||||
appsSvc: testSvc.AppsService,
|
||||
svc: svc,
|
||||
}
|
||||
|
||||
missingAppId := uint(999)
|
||||
transaction, err := theAPI.CreateInvoice(ctx, 1000, "Hello world", &missingAppId)
|
||||
|
||||
assert.Nil(t, transaction)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, "app does not exist", err.Error())
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ export const ALBY_MIN_HOSTED_BALANCE_FOR_FIRST_CHANNEL = 10_000;
|
|||
export const LIST_TRANSACTIONS_LIMIT = 20;
|
||||
export const LIST_APPS_LIMIT = 20;
|
||||
export const MAX_FREE_SUBWALLETS = 3;
|
||||
export const APP_SELECT_APPS_LIMIT = 100;
|
||||
export const PAY_FROM_SELECT_APPS_LIMIT = 100;
|
||||
|
||||
export const SUPPORT_ALBY_CONNECTION_NAME = `ZapPlanner - Alby Hub`;
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import { useInfo } from "src/hooks/useInfo";
|
|||
import { useTransaction } from "src/hooks/useTransaction";
|
||||
import { copyToClipboard } from "src/lib/clipboard";
|
||||
import { cn } from "src/lib/utils";
|
||||
import ReceiveToSelect from "src/screens/wallet/receive/ReceiveToSelect";
|
||||
import { CreateInvoiceRequest, Transaction } from "src/types";
|
||||
import { request } from "src/utils/request";
|
||||
|
||||
|
|
@ -60,6 +61,7 @@ export default function ReceiveInvoice() {
|
|||
React.useState(false);
|
||||
const [amountSat, setAmountSat] = React.useState<string>("");
|
||||
const [description, setDescription] = React.useState<string>("");
|
||||
const [toAppId, setToAppId] = React.useState<number>();
|
||||
const [transaction, setTransaction] = React.useState<Transaction | null>(
|
||||
null
|
||||
);
|
||||
|
|
@ -125,6 +127,7 @@ export default function ReceiveInvoice() {
|
|||
body: JSON.stringify({
|
||||
amountMsat: (parseInt(amountSat) || 0) * 1000,
|
||||
description,
|
||||
toAppId,
|
||||
} as CreateInvoiceRequest),
|
||||
});
|
||||
|
||||
|
|
@ -337,6 +340,7 @@ export default function ReceiveInvoice() {
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
<ReceiveToSelect appId={toAppId} onChange={setToAppId} />
|
||||
<LoadingButton
|
||||
className={cn(
|
||||
"w-full",
|
||||
|
|
|
|||
138
frontend/src/screens/wallet/receive/ReceiveToSelect.tsx
Normal file
138
frontend/src/screens/wallet/receive/ReceiveToSelect.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
"use client";
|
||||
|
||||
import { WalletIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
import AppAvatar from "src/components/AppAvatar";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
useComboboxAnchor,
|
||||
} from "src/components/ui/combobox";
|
||||
import { InputGroupAddon } from "src/components/ui/input-group";
|
||||
import { Label } from "src/components/ui/label";
|
||||
import { APP_SELECT_APPS_LIMIT } from "src/constants";
|
||||
import { useApps } from "src/hooks/useApps";
|
||||
import { getAppDisplayName } from "src/lib/utils";
|
||||
import { App } from "src/types";
|
||||
|
||||
const LIGHTNING_BALANCE = "lightning-balance";
|
||||
const LIGHTNING_BALANCE_LABEL = "Lightning Balance";
|
||||
|
||||
type ReceiveToOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
app?: App;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
appId?: number;
|
||||
onChange(appId: number | undefined): void;
|
||||
};
|
||||
|
||||
function LightningOption() {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-6 items-center justify-center rounded-lg bg-muted">
|
||||
<WalletIcon className="size-3 text-muted-foreground" />
|
||||
</div>
|
||||
<div>{LIGHTNING_BALANCE_LABEL}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppOption({ app }: { app: App }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<AppAvatar app={app} className="size-6 rounded-lg" />
|
||||
<div className="min-w-0">
|
||||
<div>{getAppDisplayName(app.name)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ReceiveToSelect({ appId, onChange }: Props) {
|
||||
const anchorRef = useComboboxAnchor();
|
||||
const [search, setSearch] = React.useState("");
|
||||
const { data: appsData } = useApps(APP_SELECT_APPS_LIMIT, undefined, {
|
||||
name: search,
|
||||
});
|
||||
|
||||
const apps = React.useMemo(
|
||||
() =>
|
||||
[...(appsData?.apps || [])].sort((a, b) =>
|
||||
getAppDisplayName(a.name).localeCompare(getAppDisplayName(b.name))
|
||||
),
|
||||
[appsData?.apps]
|
||||
);
|
||||
|
||||
const options = React.useMemo<ReceiveToOption[]>(
|
||||
() => [
|
||||
{ value: LIGHTNING_BALANCE, label: LIGHTNING_BALANCE_LABEL },
|
||||
...apps.map((app) => ({
|
||||
value: app.id.toString(),
|
||||
label: getAppDisplayName(app.name),
|
||||
app,
|
||||
})),
|
||||
],
|
||||
[apps]
|
||||
);
|
||||
|
||||
const selectedOption = options.find((opt) =>
|
||||
appId ? opt.value === appId.toString() : undefined
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid w-full gap-1.5">
|
||||
<Label>Receive to</Label>
|
||||
<Combobox
|
||||
items={options}
|
||||
value={selectedOption}
|
||||
itemToStringValue={(option) => option.value}
|
||||
onInputValueChange={setSearch}
|
||||
onValueChange={(option) =>
|
||||
onChange(
|
||||
!option || option.value === LIGHTNING_BALANCE
|
||||
? undefined
|
||||
: Number(option.value)
|
||||
)
|
||||
}
|
||||
>
|
||||
<div ref={anchorRef} className="w-full">
|
||||
<ComboboxInput placeholder={LIGHTNING_BALANCE_LABEL}>
|
||||
<InputGroupAddon>
|
||||
{selectedOption?.app ? (
|
||||
<AppAvatar
|
||||
app={selectedOption.app}
|
||||
className="size-6 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-6 items-center justify-center rounded-sm bg-muted">
|
||||
<WalletIcon className="size-3 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</InputGroupAddon>
|
||||
</ComboboxInput>
|
||||
</div>
|
||||
<ComboboxContent anchor={anchorRef}>
|
||||
<ComboboxEmpty>No connections found.</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(option: ReceiveToOption) => (
|
||||
<ComboboxItem key={option.value} value={option}>
|
||||
{option.app ? (
|
||||
<AppOption app={option.app} />
|
||||
) : (
|
||||
<LightningOption />
|
||||
)}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -378,6 +378,7 @@ export type CreateInvoiceRequest = {
|
|||
amountSat?: number;
|
||||
amountMsat?: number;
|
||||
description: string;
|
||||
toAppId?: number;
|
||||
};
|
||||
|
||||
export type PayInvoiceRequest = {
|
||||
|
|
|
|||
|
|
@ -687,7 +687,7 @@ func (httpSvc *HttpService) makeInvoiceHandler(c echo.Context) error {
|
|||
amountMsat = *resolvedAmountMsat
|
||||
}
|
||||
|
||||
invoice, err := httpSvc.api.CreateInvoice(c.Request().Context(), amountMsat, makeInvoiceRequest.Description)
|
||||
invoice, err := httpSvc.api.CreateInvoice(c.Request().Context(), amountMsat, makeInvoiceRequest.Description, makeInvoiceRequest.ToAppID)
|
||||
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, ErrorResponse{
|
||||
|
|
|
|||
|
|
@ -600,7 +600,7 @@ func (app *WailsApp) WailsRequestRouter(route string, method string, body string
|
|||
if resolvedAmountMsat != nil {
|
||||
amountMsat = *resolvedAmountMsat
|
||||
}
|
||||
invoice, err := app.api.CreateInvoice(ctx, amountMsat, makeInvoiceRequest.Description)
|
||||
invoice, err := app.api.CreateInvoice(ctx, amountMsat, makeInvoiceRequest.Description, makeInvoiceRequest.ToAppID)
|
||||
if err != nil {
|
||||
return WailsRequestRouterResponse{Body: nil, Error: err.Error()}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue