feat: track last settled transaction time for apps (#2214)

* feat: track last settled transaction time for apps

* fix: use default subwallet ordering and app_id logging in event handler

* chore: rename to last_settled_transaction_at and remove last tx migration

* chore: split app settlement update and budget check

* chore: extract app display name helper into utils function
This commit is contained in:
Adithya Vardhan 2026-04-09 16:26:45 +05:30 committed by GitHub
parent 18efdcdf11
commit b1bd7c2587
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 163 additions and 120 deletions

View file

@ -456,22 +456,23 @@ func (api *api) GetApp(dbApp *db.App) (*App, error) {
}
response := App{
ID: dbApp.ID,
Name: dbApp.Name,
Description: dbApp.Description,
CreatedAt: dbApp.CreatedAt,
UpdatedAt: dbApp.UpdatedAt,
AppPubkey: dbApp.AppPubkey,
ExpiresAt: expiresAt,
MaxAmountSat: maxAmount,
Scopes: requestMethods,
BudgetUsage: budgetUsage / 1000,
BudgetRenewal: paySpecificPermission.BudgetRenewal,
Isolated: dbApp.Isolated,
Metadata: metadata,
WalletPubkey: walletPubkey,
UniqueWalletPubkey: uniqueWalletPubkey,
LastUsedAt: dbApp.LastUsedAt,
ID: dbApp.ID,
Name: dbApp.Name,
Description: dbApp.Description,
CreatedAt: dbApp.CreatedAt,
UpdatedAt: dbApp.UpdatedAt,
AppPubkey: dbApp.AppPubkey,
ExpiresAt: expiresAt,
MaxAmountSat: maxAmount,
Scopes: requestMethods,
BudgetUsage: budgetUsage / 1000,
BudgetRenewal: paySpecificPermission.BudgetRenewal,
Isolated: dbApp.Isolated,
Metadata: metadata,
WalletPubkey: walletPubkey,
UniqueWalletPubkey: uniqueWalletPubkey,
LastUsedAt: dbApp.LastUsedAt,
LastSettledTransactionAt: dbApp.LastSettledTransactionAt,
}
if dbApp.Isolated {
@ -525,15 +526,7 @@ func (api *api) ListApps(limit uint64, offset uint64, filters ListAppsFilters, o
}
}
if orderBy == "" {
orderBy = "last_used_at"
}
if orderBy == "last_used_at" {
// when ordering by last used at, apps with last_used_at is NULL should be ordered last
orderBy = "last_used_at IS NULL, " + orderBy
}
query = query.Order(orderBy + " DESC")
query = query.Order(resolveAppOrderBy(orderBy))
if limit == 0 {
limit = 100
@ -590,16 +583,17 @@ func (api *api) ListApps(limit uint64, offset uint64, filters ListAppsFilters, o
uniqueWalletPubkey = true
}
apiApp := App{
ID: dbApp.ID,
Name: dbApp.Name,
Description: dbApp.Description,
CreatedAt: dbApp.CreatedAt,
UpdatedAt: dbApp.UpdatedAt,
AppPubkey: dbApp.AppPubkey,
Isolated: dbApp.Isolated,
WalletPubkey: walletPubkey,
UniqueWalletPubkey: uniqueWalletPubkey,
LastUsedAt: dbApp.LastUsedAt,
ID: dbApp.ID,
Name: dbApp.Name,
Description: dbApp.Description,
CreatedAt: dbApp.CreatedAt,
UpdatedAt: dbApp.UpdatedAt,
AppPubkey: dbApp.AppPubkey,
Isolated: dbApp.Isolated,
WalletPubkey: walletPubkey,
UniqueWalletPubkey: uniqueWalletPubkey,
LastUsedAt: dbApp.LastUsedAt,
LastSettledTransactionAt: dbApp.LastSettledTransactionAt,
}
if dbApp.Isolated {
@ -650,6 +644,17 @@ func (api *api) ListApps(limit uint64, offset uint64, filters ListAppsFilters, o
}, nil
}
func resolveAppOrderBy(orderBy string) string {
switch orderBy {
case "created_at":
return "created_at DESC"
case "last_settled_transaction":
return "last_settled_transaction_at IS NULL, last_settled_transaction_at DESC"
default:
return "last_used_at IS NULL, last_used_at DESC"
}
}
func (api *api) ListChannels(ctx context.Context) ([]Channel, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {

View file

@ -89,23 +89,24 @@ type API interface {
var ErrLNClientNotStarted = errors.New("LNClient not started")
type App struct {
ID uint `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
AppPubkey string `json:"appPubkey"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
LastUsedAt *time.Time `json:"lastUsedAt"`
ExpiresAt *time.Time `json:"expiresAt"`
Scopes []string `json:"scopes"`
MaxAmountSat uint64 `json:"maxAmount"`
BudgetUsage uint64 `json:"budgetUsage"`
BudgetRenewal string `json:"budgetRenewal"`
Isolated bool `json:"isolated"`
WalletPubkey string `json:"walletPubkey"`
UniqueWalletPubkey bool `json:"uniqueWalletPubkey"`
Balance int64 `json:"balance"`
Metadata Metadata `json:"metadata,omitempty"`
ID uint `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
AppPubkey string `json:"appPubkey"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
LastUsedAt *time.Time `json:"lastUsedAt"`
LastSettledTransactionAt *time.Time `json:"lastSettledTransactionAt"`
ExpiresAt *time.Time `json:"expiresAt"`
Scopes []string `json:"scopes"`
MaxAmountSat uint64 `json:"maxAmount"`
BudgetUsage uint64 `json:"budgetUsage"`
BudgetRenewal string `json:"budgetRenewal"`
Isolated bool `json:"isolated"`
WalletPubkey string `json:"walletPubkey"`
UniqueWalletPubkey bool `json:"uniqueWalletPubkey"`
Balance int64 `json:"balance"`
Metadata Metadata `json:"metadata,omitempty"`
}
type ListAppsFilters struct {

View file

@ -0,0 +1,29 @@
package migrations
import (
_ "embed"
"text/template"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
const appLastSettledTransactionMigration = `ALTER TABLE apps ADD COLUMN last_settled_transaction_at {{ .Timestamp }};`
var appLastSettledTransactionMigrationTmpl = template.Must(template.New("appLastSettledTransactionMigration").Parse(appLastSettledTransactionMigration))
var _202604081200_app_last_settled_transaction = &gormigrate.Migration{
ID: "202604081200_app_last_settled_transaction",
Migrate: func(tx *gorm.DB) error {
err := exec(tx, appLastSettledTransactionMigrationTmpl)
if err != nil {
return err
}
return nil
},
Rollback: func(tx *gorm.DB) error {
return nil
},
}

View file

@ -38,6 +38,7 @@ func Migrate(gormDB *gorm.DB) error {
_202508151405_swap_xpub,
_202508192137_forwards,
_202509031250_transactions_updated_at_index,
_202604081200_app_last_settled_transaction,
})
return m.Migrate()

View file

@ -16,16 +16,17 @@ type UserConfig struct {
}
type App struct {
ID uint
Name string `validate:"required"`
Description string
AppPubkey string `validate:"required"`
WalletPubkey *string
CreatedAt time.Time
UpdatedAt time.Time
LastUsedAt *time.Time
Isolated bool
Metadata datatypes.JSON
ID uint
Name string `validate:"required"`
Description string
AppPubkey string `validate:"required"`
WalletPubkey *string
CreatedAt time.Time
UpdatedAt time.Time
LastUsedAt *time.Time
LastSettledTransactionAt *time.Time
Isolated bool
Metadata datatypes.JSON
}
type AppPermission struct {

View file

@ -27,11 +27,10 @@ import {
DialogTitle,
DialogTrigger,
} from "src/components/ui/dialog";
import { ALBY_ACCOUNT_APP_NAME } from "src/constants";
import { useApp } from "src/hooks/useApp";
import { useSwap } from "src/hooks/useSwaps";
import { copyToClipboard } from "src/lib/clipboard";
import { cn } from "src/lib/utils";
import { cn, getAppDisplayName } from "src/lib/utils";
import { Transaction } from "src/types";
dayjs.extend(utc);
@ -154,7 +153,7 @@ function TransactionItem({ tx }: Props) {
{app && (
<div
className="absolute -bottom-1 -right-1"
title={`${typeStateText} via ${app.name === ALBY_ACCOUNT_APP_NAME ? "Alby Account" : app.name}`}
title={`${typeStateText} via ${getAppDisplayName(app.name)}`}
>
<AppAvatar
app={app}
@ -244,11 +243,7 @@ function TransactionItem({ tx }: Props) {
<div className="mt-8">
<p>App</p>
<Link to={`/apps/${app.id}`}>
<p className="font-semibold">
{app.name === ALBY_ACCOUNT_APP_NAME
? "Alby Account"
: app.name}
</p>
<p className="font-semibold">{getAppDisplayName(app.name)}</p>
</Link>
</div>
)}

View file

@ -9,13 +9,18 @@ import {
CardTitle,
} from "src/components/ui/card";
import { LinkButton } from "src/components/ui/custom/link-button";
import { ALBY_ACCOUNT_APP_NAME } from "src/constants";
import { useApps } from "src/hooks/useApps";
import { getAppDisplayName } from "src/lib/utils";
export function LatestUsedAppsWidget() {
const { data: appsData } = useApps(3, undefined, undefined, "last_used_at");
const { data: appsData } = useApps(
3,
undefined,
undefined,
"last_settled_transaction"
);
const apps = appsData?.apps;
const usedApps = apps?.filter((x) => x.lastUsedAt);
const usedApps = apps?.filter((x) => x.lastSettledTransactionAt);
if (!usedApps?.length) {
return null;
@ -32,28 +37,22 @@ export function LatestUsedAppsWidget() {
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4">
{usedApps
.sort(
(a, b) =>
new Date(b.lastUsedAt ?? 0).getTime() -
new Date(a.lastUsedAt ?? 0).getTime()
)
.map((app) => (
<Link key={app.id} to={`/apps/${app.id}`}>
<div className="flex items-center w-full gap-4">
<AppAvatar app={app} className="w-14 h-14 rounded-lg" />
<p className="text-sm font-medium flex-1 truncate">
{app.name === ALBY_ACCOUNT_APP_NAME
? "Alby Account"
: app.name}
</p>
<p className="text-xs text-muted-foreground">
{app.lastUsedAt ? dayjs(app.lastUsedAt).fromNow() : "never"}
</p>
<ChevronRightIcon className="text-muted-foreground size-8" />
</div>
</Link>
))}
{usedApps.map((app) => (
<Link key={app.id} to={`/apps/${app.id}`}>
<div className="flex items-center w-full gap-4">
<AppAvatar app={app} className="w-14 h-14 rounded-lg" />
<p className="text-sm font-medium flex-1 truncate">
{getAppDisplayName(app.name)}
</p>
<p className="text-xs text-muted-foreground">
{app.lastSettledTransactionAt
? dayjs(app.lastSettledTransactionAt).fromNow()
: "never"}
</p>
<ChevronRightIcon className="text-muted-foreground size-8" />
</div>
</Link>
))}
</CardContent>
</Card>
);

View file

@ -15,7 +15,7 @@ export function useApps(
unused?: boolean;
subWallets?: boolean;
},
orderBy?: "last_used_at" | "created_at",
orderBy?: "last_used_at" | "last_settled_transaction" | "created_at",
isEnabled = true
) {
const offset = (page - 1) * limit;

View file

@ -1,4 +1,5 @@
import { clsx, type ClassValue } from "clsx";
import { ALBY_ACCOUNT_APP_NAME } from "src/constants";
import { BudgetRenewalType } from "src/types";
import { twMerge } from "tailwind-merge";
@ -81,3 +82,7 @@ export function getBudgetRenewalLabel(renewalType: BudgetRenewalType): string {
return "";
}
}
export function getAppDisplayName(name: string) {
return name === ALBY_ACCOUNT_APP_NAME ? "Alby Account" : name;
}

View file

@ -68,7 +68,7 @@ import { useAlbyMe } from "src/hooks/useAlbyMe";
import { useApp } from "src/hooks/useApp";
import { useAppsForAppStoreApp } from "src/hooks/useApps";
import { useCapabilities } from "src/hooks/useCapabilities";
import { cn } from "src/lib/utils";
import { cn, getAppDisplayName } from "src/lib/utils";
function AppDetails() {
const { id } = useParams() as { id: string };
@ -181,8 +181,7 @@ function AppInternal({ app, refetchApp, capabilities }: AppInternalProps) {
}
};
const appName =
app.name === ALBY_ACCOUNT_APP_NAME ? "Alby Account" : app.name;
const appName = getAppDisplayName(app.name);
const appStoreApp = getAppStoreApp(app);
const connectedApps = useAppsForAppStoreApp(appStoreApp);

View file

@ -19,14 +19,9 @@ import { handleRequestError } from "src/utils/handleRequestError";
export function NewSubwallet() {
const navigate = useNavigate();
const [name, setName] = React.useState("");
const { data: subwalletAppsData } = useApps(
undefined,
undefined,
{
subWallets: true,
},
"created_at"
);
const { data: subwalletAppsData } = useApps(undefined, undefined, {
subWallets: true,
});
const { data: info } = useInfo();
const { data: albyMe, error: albyMeError } = useAlbyMe();

View file

@ -120,6 +120,7 @@ export interface App {
createdAt: string;
updatedAt: string;
lastUsedAt?: string;
lastSettledTransactionAt?: string;
expiresAt?: string;
isolated: boolean;
balance: number;

View file

@ -85,7 +85,7 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, pool nostrmodels.Simpl
err = svc.db.Model(&app).Update("last_used_at", &now).Error
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"it": app.ID,
"app_id": app.ID,
}).WithError(err).Error("Failed to update app last used time")
}

View file

@ -1404,13 +1404,13 @@ func (svc *transactionsService) markTransactionSettled(tx *gorm.DB, dbTransactio
return &existingSettledTransaction, nil
}
now := time.Now()
settledAt := time.Now()
err := tx.Model(dbTransaction).Updates(map[string]interface{}{
"State": constants.TRANSACTION_STATE_SETTLED,
"Preimage": &preimage,
"FeeMsat": fee,
"FeeReserveMsat": 0,
"SettledAt": &now,
"SettledAt": &settledAt,
"SelfPayment": selfPayment,
}).Error
if err != nil {
@ -1435,28 +1435,40 @@ func (svc *transactionsService) markTransactionSettled(tx *gorm.DB, dbTransactio
Properties: dbTransaction,
})
if dbTransaction.Type == constants.TRANSACTION_TYPE_OUTGOING && dbTransaction.AppId != nil {
svc.checkBudgetUsage(dbTransaction, tx)
if dbTransaction.AppId != nil {
var app db.App
result := tx.Limit(1).Find(&app, &db.App{
ID: *dbTransaction.AppId,
})
if result.RowsAffected == 0 {
logger.Logger.WithField("app_id", dbTransaction.AppId).Error("failed to find app by id")
return dbTransaction, nil
}
svc.updateAppLastSettledTransactionAt(&app, tx, &settledAt)
if dbTransaction.Type == constants.TRANSACTION_TYPE_OUTGOING {
svc.checkBudgetUsage(&app, dbTransaction, tx)
}
}
return dbTransaction, nil
}
func (svc *transactionsService) checkBudgetUsage(dbTransaction *db.Transaction, gormTransaction *gorm.DB) {
var app db.App
result := gormTransaction.Limit(1).Find(&app, &db.App{
ID: *dbTransaction.AppId,
})
if result.RowsAffected == 0 {
logger.Logger.WithField("app_id", dbTransaction.AppId).Error("failed to find app by id")
func (svc *transactionsService) updateAppLastSettledTransactionAt(app *db.App, gormTransaction *gorm.DB, settledAt *time.Time) {
if err := gormTransaction.Model(app).Update("last_settled_transaction_at", settledAt).Error; err != nil {
logger.Logger.WithField("app_id", app.ID).WithError(err).Error("failed to update app last settled transaction time")
return
}
}
func (svc *transactionsService) checkBudgetUsage(app *db.App, dbTransaction *db.Transaction, gormTransaction *gorm.DB) {
if app.Isolated {
return
}
var appPermission db.AppPermission
result = gormTransaction.Limit(1).Find(&appPermission, &db.AppPermission{
result := gormTransaction.Limit(1).Find(&appPermission, &db.AppPermission{
AppId: app.ID,
Scope: constants.PAY_INVOICE_SCOPE,
})