Improve CORS origin handling; extend invoice repo/service and payments dispatch; rate limit and nginx config updates
Made-with: Love
This commit is contained in:
@@ -149,6 +149,31 @@ func (r *Repo) HasUnpaidForPubkey(ctx context.Context, pubkey string) (bool, err
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
// GetActiveUnpaidByPubkey returns the most recent unpaid, unexpired invoice for the pubkey, or nil if none.
|
||||
func (r *Repo) GetActiveUnpaidByPubkey(ctx context.Context, pubkey string) (*PendingInvoice, error) {
|
||||
row := r.db.QueryRowContext(ctx, `SELECT `+invCols+` FROM pending_invoices
|
||||
WHERE pubkey = ? AND paid = 0 AND expires_at > ?
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
pubkey, time.Now().UTC().Format(time.RFC3339))
|
||||
p, err := scanInvoice(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// DeleteActiveUnpaidForPubkey removes all unpaid, unexpired invoices for the pubkey so a new
|
||||
// invoice can be issued when the user switches plan (replacing the previous Bolt11).
|
||||
func (r *Repo) DeleteActiveUnpaidForPubkey(ctx context.Context, pubkey string) error {
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`DELETE FROM pending_invoices WHERE pubkey = ? AND paid = 0 AND expires_at > ?`,
|
||||
pubkey, time.Now().UTC().Format(time.RFC3339))
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) PurgeOldUnpaid(ctx context.Context) error {
|
||||
cutoff := time.Now().UTC().Add(-1 * time.Hour).Format(time.RFC3339)
|
||||
_, err := r.db.ExecContext(ctx, `DELETE FROM pending_invoices WHERE paid = 0 AND expires_at < ?`, cutoff)
|
||||
|
||||
@@ -43,9 +43,20 @@ var (
|
||||
ErrUsernameTaken = errors.New("username taken")
|
||||
ErrInvalidYears = errors.New("invalid years")
|
||||
ErrLifetimeAccess = errors.New("user already has lifetime access")
|
||||
// Deprecated: Create no longer returns this; an existing unpaid invoice is returned instead.
|
||||
ErrPendingInvoiceExists = errors.New("pending unpaid invoice already exists")
|
||||
)
|
||||
|
||||
func pendingMatchesRequest(p *PendingInvoice, req CreateRequest) bool {
|
||||
if p.SubscriptionType != req.SubscriptionType {
|
||||
return false
|
||||
}
|
||||
if req.SubscriptionType == user.SubYearly {
|
||||
return p.Years == req.Years
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Create computes amount, calls LNbits, persists pending invoice. Detects renewal.
|
||||
func (s *Service) Create(ctx context.Context, req CreateRequest) (*PendingInvoice, error) {
|
||||
if !req.SubscriptionType.Valid() {
|
||||
@@ -62,12 +73,19 @@ func (s *Service) Create(ctx context.Context, req CreateRequest) (*PendingInvoic
|
||||
req.Years = 0
|
||||
}
|
||||
|
||||
hasPendingPubkey, err := s.repo.HasUnpaidForPubkey(ctx, req.Pubkey)
|
||||
pendingExisting, err := s.repo.GetActiveUnpaidByPubkey(ctx, req.Pubkey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hasPendingPubkey {
|
||||
return nil, ErrPendingInvoiceExists
|
||||
if pendingExisting != nil {
|
||||
if pendingMatchesRequest(pendingExisting, req) {
|
||||
// Idempotent resume: same Bolt11 until paid or LN invoice expiry.
|
||||
return pendingExisting, nil
|
||||
}
|
||||
// Replace pending Bolt11 with one for the newly requested plan.
|
||||
if err := s.repo.DeleteActiveUnpaidForPubkey(ctx, req.Pubkey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
username := user.NormalizeUsername(req.Username)
|
||||
|
||||
@@ -127,26 +127,66 @@ func TestCreate_BlocksActiveLifetimeUser(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_RejectsDuplicatePending(t *testing.T) {
|
||||
func TestCreate_ResumesDuplicatePending(t *testing.T) {
|
||||
svc, _, _ := newServiceFixture(t)
|
||||
|
||||
if _, err := svc.Create(context.Background(), invoice.CreateRequest{
|
||||
Pubkey: testHex,
|
||||
Username: "alice",
|
||||
SubscriptionType: user.SubYearly,
|
||||
Years: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("first create: %v", err)
|
||||
}
|
||||
|
||||
_, err := svc.Create(context.Background(), invoice.CreateRequest{
|
||||
first, err := svc.Create(context.Background(), invoice.CreateRequest{
|
||||
Pubkey: testHex,
|
||||
Username: "alice",
|
||||
SubscriptionType: user.SubYearly,
|
||||
Years: 1,
|
||||
})
|
||||
if !errors.Is(err, invoice.ErrPendingInvoiceExists) {
|
||||
t.Fatalf("expected ErrPendingInvoiceExists, got %v", err)
|
||||
if err != nil {
|
||||
t.Fatalf("first create: %v", err)
|
||||
}
|
||||
|
||||
second, err := svc.Create(context.Background(), invoice.CreateRequest{
|
||||
Pubkey: testHex,
|
||||
Username: "alice",
|
||||
SubscriptionType: user.SubYearly,
|
||||
Years: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("second create should resume pending: %v", err)
|
||||
}
|
||||
if second.PaymentHash != first.PaymentHash || second.PaymentRequest != first.PaymentRequest {
|
||||
t.Fatalf("expected same pending invoice on resume, got hash=%q vs %q", second.PaymentHash, first.PaymentHash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_SupersedesDifferentPendingPlan(t *testing.T) {
|
||||
svc, _, _ := newServiceFixture(t)
|
||||
|
||||
first, err := svc.Create(context.Background(), invoice.CreateRequest{
|
||||
Pubkey: testHex,
|
||||
Username: "alice",
|
||||
SubscriptionType: user.SubLifetime,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("lifetime pending: %v", err)
|
||||
}
|
||||
|
||||
second, err := svc.Create(context.Background(), invoice.CreateRequest{
|
||||
Pubkey: testHex,
|
||||
Username: "alice",
|
||||
SubscriptionType: user.SubYearly,
|
||||
Years: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("yearly after lifetime pending: %v", err)
|
||||
}
|
||||
stored, err := svc.Repo().Get(context.Background(), second.PaymentHash)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.SubscriptionType != user.SubYearly || stored.Years != 1 || stored.AmountSats != 1000 {
|
||||
t.Fatalf("DB row should reflect yearly plan after supersede: %+v", stored)
|
||||
}
|
||||
if second.SubscriptionType != user.SubYearly || second.Years != 1 || second.AmountSats != 1000 {
|
||||
t.Fatalf("unexpected yearly invoice: %+v", second)
|
||||
}
|
||||
if first.SubscriptionType == second.SubscriptionType || first.AmountSats == second.AmountSats {
|
||||
t.Fatalf("expected plan switch lifetime -> yearly")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user