commit
53a02eddd3
5 changed files with 222 additions and 29 deletions
|
|
@ -25,6 +25,7 @@ CREATE TABLE cards (
|
||||||
one_time_code CHAR(32) NOT NULL DEFAULT '',
|
one_time_code CHAR(32) NOT NULL DEFAULT '',
|
||||||
one_time_code_expiry TIMESTAMPTZ DEFAULT NOW() + INTERVAL '1 DAY',
|
one_time_code_expiry TIMESTAMPTZ DEFAULT NOW() + INTERVAL '1 DAY',
|
||||||
one_time_code_used CHAR(1) NOT NULL DEFAULT 'Y',
|
one_time_code_used CHAR(1) NOT NULL DEFAULT 'Y',
|
||||||
|
allow_negative_balance CHAR(1) NOT NULL DEFAULT 'N',
|
||||||
PRIMARY KEY(card_id)
|
PRIMARY KEY(card_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
139
database.go
139
database.go
|
|
@ -8,7 +8,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Card struct {
|
type card struct {
|
||||||
card_id int
|
card_id int
|
||||||
card_guid string
|
card_guid string
|
||||||
k0_auth_key string
|
k0_auth_key string
|
||||||
|
|
@ -22,11 +22,12 @@ type Card struct {
|
||||||
lnurlw_enable string
|
lnurlw_enable string
|
||||||
tx_limit_sats int
|
tx_limit_sats int
|
||||||
day_limit_sats int
|
day_limit_sats int
|
||||||
lnurlp_enable string
|
lnurlp_enable string
|
||||||
email_address string
|
email_address string
|
||||||
email_enable string
|
email_enable string
|
||||||
one_time_code string
|
one_time_code string
|
||||||
card_name string
|
card_name string
|
||||||
|
allow_negative_balance string
|
||||||
}
|
}
|
||||||
|
|
||||||
type payment struct {
|
type payment struct {
|
||||||
|
|
@ -36,6 +37,14 @@ type payment struct {
|
||||||
paid_flag string
|
paid_flag string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type transaction struct {
|
||||||
|
card_id int
|
||||||
|
tx_id int
|
||||||
|
tx_type string
|
||||||
|
tx_amount_msats int
|
||||||
|
tx_time string
|
||||||
|
}
|
||||||
|
|
||||||
func db_open() (*sql.DB, error) {
|
func db_open() (*sql.DB, error) {
|
||||||
|
|
||||||
// get connection string from environment variables
|
// get connection string from environment variables
|
||||||
|
|
@ -55,9 +64,9 @@ func db_open() (*sql.DB, error) {
|
||||||
return db, nil
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func db_get_new_card(one_time_code string) (*Card, error) {
|
func db_get_new_card(one_time_code string) (*card, error) {
|
||||||
|
|
||||||
c := Card{}
|
c := card{}
|
||||||
|
|
||||||
db, err := db_open()
|
db, err := db_open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -191,7 +200,7 @@ func db_get_card_id_for_r_hash(r_hash string) (int, error) {
|
||||||
return card_id, nil
|
return card_id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func db_get_cards_blank_uid() ([]Card, error) {
|
func db_get_cards_blank_uid() ([]card, error) {
|
||||||
|
|
||||||
// open the database
|
// open the database
|
||||||
|
|
||||||
|
|
@ -217,17 +226,17 @@ func db_get_cards_blank_uid() ([]Card, error) {
|
||||||
|
|
||||||
// prepare the results
|
// prepare the results
|
||||||
|
|
||||||
var cards []Card
|
var cards []card
|
||||||
|
|
||||||
// Loop through rows, using Scan to assign column data to struct fields.
|
// Loop through rows, using Scan to assign column data to struct fields.
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var card Card
|
var c card
|
||||||
err := rows.Scan(&card.card_id, &card.k2_cmac_key)
|
err := rows.Scan(&c.card_id, &c.k2_cmac_key)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cards, err
|
return cards, err
|
||||||
}
|
}
|
||||||
cards = append(cards, card)
|
cards = append(cards, c)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = rows.Err()
|
err = rows.Err()
|
||||||
|
|
@ -262,9 +271,9 @@ func db_update_card_uid_ctr(card_id int, uid string, ctr uint32) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func db_get_card_from_uid(card_uid string) (*Card, error) {
|
func db_get_card_from_uid(card_uid string) (*card, error) {
|
||||||
|
|
||||||
c := Card{}
|
c := card{}
|
||||||
|
|
||||||
db, err := db_open()
|
db, err := db_open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -293,9 +302,9 @@ func db_get_card_from_uid(card_uid string) (*Card, error) {
|
||||||
return &c, nil
|
return &c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func db_get_card_from_card_id(card_id int) (*Card, error) {
|
func db_get_card_from_card_id(card_id int) (*card, error) {
|
||||||
|
|
||||||
c := Card{}
|
c := card{}
|
||||||
|
|
||||||
db, err := db_open()
|
db, err := db_open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -303,10 +312,11 @@ func db_get_card_from_card_id(card_id int) (*Card, error) {
|
||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
sqlStatement := `SELECT card_id, k2_cmac_key, uid,` +
|
sqlStatement := `SELECT card_id, k2_cmac_key, uid, ` +
|
||||||
` last_counter_value, lnurlw_request_timeout_sec,` +
|
`last_counter_value, lnurlw_request_timeout_sec, ` +
|
||||||
` lnurlw_enable, tx_limit_sats, day_limit_sats, email_enable, email_address` +
|
`lnurlw_enable, tx_limit_sats, day_limit_sats, ` +
|
||||||
` FROM cards WHERE card_id=$1;`
|
`email_enable, email_address, card_name, ` +
|
||||||
|
`allow_negative_balance FROM cards WHERE card_id=$1;`
|
||||||
row := db.QueryRow(sqlStatement, card_id)
|
row := db.QueryRow(sqlStatement, card_id)
|
||||||
err = row.Scan(
|
err = row.Scan(
|
||||||
&c.card_id,
|
&c.card_id,
|
||||||
|
|
@ -318,7 +328,9 @@ func db_get_card_from_card_id(card_id int) (*Card, error) {
|
||||||
&c.tx_limit_sats,
|
&c.tx_limit_sats,
|
||||||
&c.day_limit_sats,
|
&c.day_limit_sats,
|
||||||
&c.email_enable,
|
&c.email_enable,
|
||||||
&c.email_address)
|
&c.email_address,
|
||||||
|
&c.card_name,
|
||||||
|
&c.allow_negative_balance)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &c, err
|
return &c, err
|
||||||
}
|
}
|
||||||
|
|
@ -584,3 +596,90 @@ func db_get_card_totals(card_id int) (int, error) {
|
||||||
|
|
||||||
return day_total_sats, nil
|
return day_total_sats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func db_get_card_txs(card_id int, max_txs int) ([]transaction, error) {
|
||||||
|
// open the database
|
||||||
|
|
||||||
|
db, err := db_open()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// query the database
|
||||||
|
|
||||||
|
sqlStatement := `SELECT card_id, ` +
|
||||||
|
`card_payments.card_payment_id AS tx_id, 'payment' AS tx_type, ` +
|
||||||
|
`amount_msats as tx_amount_msats, ` +
|
||||||
|
`TO_CHAR(payment_status_time, 'DD/MM/YYYY HH:MI:SS') AS tx_time ` +
|
||||||
|
`FROM card_payments WHERE card_id = $1 AND payment_status != 'FAILED' ` +
|
||||||
|
`AND payment_status != '' ` +
|
||||||
|
`AND amount_msats != 0 UNION SELECT card_id, card_receipts.card_receipt_id AS tx_id, ` +
|
||||||
|
`'receipt' AS tx_type, amount_msats as tx_amount_msats, ` +
|
||||||
|
`TO_CHAR(receipt_status_time, 'DD/MM/YYYY HH:MI:SS') AS tx_time ` +
|
||||||
|
`FROM card_receipts WHERE card_id = $1 ` +
|
||||||
|
`AND receipt_status = 'SETTLED' ORDER BY tx_time DESC LIMIT $2`
|
||||||
|
|
||||||
|
rows, err := db.Query(sqlStatement, card_id, max_txs)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
// prepare the results
|
||||||
|
|
||||||
|
var transactions []transaction
|
||||||
|
|
||||||
|
// Loop through rows, using Scan to assign column data to struct fields.
|
||||||
|
for rows.Next() {
|
||||||
|
var t transaction
|
||||||
|
err := rows.Scan(&t.card_id, &t.tx_id, &t.tx_type, &t.tx_amount_msats, &t.tx_time)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return transactions, err
|
||||||
|
}
|
||||||
|
transactions = append(transactions, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = rows.Err()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return transactions, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return transactions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func db_get_card_total_sats(card_id int) (int, error) {
|
||||||
|
|
||||||
|
db, err := db_open()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
card_total_msats := 0
|
||||||
|
|
||||||
|
sqlStatement := `SELECT SUM(tx_amount_msats) FROM (SELECT card_id, ` +
|
||||||
|
`card_payments.card_payment_id AS tx_id, 'payment' AS tx_type, ` +
|
||||||
|
`-amount_msats as tx_amount_msats, payment_status_time AS tx_time ` +
|
||||||
|
`FROM card_payments WHERE card_id = $1 AND payment_status != 'FAILED' ` +
|
||||||
|
`AND payment_status != '' ` +
|
||||||
|
`AND amount_msats != 0 UNION SELECT card_id, card_receipts.card_receipt_id AS tx_id, ` +
|
||||||
|
`'receipt' AS tx_type, amount_msats as tx_amount_msats, ` +
|
||||||
|
`receipt_status_time AS tx_time FROM card_receipts WHERE card_id = $1 ` +
|
||||||
|
`AND receipt_status = 'SETTLED' ORDER BY tx_time) AS transactions;`
|
||||||
|
|
||||||
|
row := db.QueryRow(sqlStatement, card_id)
|
||||||
|
err = row.Scan(&card_total_msats)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
card_total_sats := card_total_msats / 1000
|
||||||
|
|
||||||
|
return card_total_sats, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
75
email.go
75
email.go
|
|
@ -8,8 +8,83 @@ import (
|
||||||
"github.com/aws/aws-sdk-go/service/ses"
|
"github.com/aws/aws-sdk-go/service/ses"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func send_balance_email(recipient_email string, card_id int) {
|
||||||
|
|
||||||
|
c, err := db_get_card_from_card_id(card_id)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
card_total_sats, err := db_get_card_total_sats(card_id)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
email_max_txs, err := strconv.Atoi(os.Getenv("EMAIL_MAX_TXS"))
|
||||||
|
if err != nil {
|
||||||
|
log.Warn(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
txs, err := db_get_card_txs(card_id, email_max_txs+1)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
subject := c.card_name + " balance = " + strconv.Itoa(card_total_sats) + " sats"
|
||||||
|
|
||||||
|
// add transactions to the email body
|
||||||
|
|
||||||
|
var html_body_sb strings.Builder
|
||||||
|
var text_body_sb strings.Builder
|
||||||
|
|
||||||
|
html_body_sb.WriteString("<!DOCTYPE html><html><head><style> table, " +
|
||||||
|
"th, td { border: 1px solid black; border-collapse: collapse; } " +
|
||||||
|
"</style></head><body>")
|
||||||
|
|
||||||
|
html_body_sb.WriteString("<h3>transactions</h3><table><tr><th>date</th><th>action</th><th>amount</th>")
|
||||||
|
text_body_sb.WriteString("transactions\n\n")
|
||||||
|
|
||||||
|
for i, tx := range txs {
|
||||||
|
|
||||||
|
if i < email_max_txs {
|
||||||
|
html_body_sb.WriteString(
|
||||||
|
"<tr>" +
|
||||||
|
"<td>" + tx.tx_time + "</td>" +
|
||||||
|
"<td>" + tx.tx_type + "</td>" +
|
||||||
|
"<td style='text-align:right'>" + strconv.Itoa(tx.tx_amount_msats/1000) + "</td>" +
|
||||||
|
"</tr>")
|
||||||
|
} else {
|
||||||
|
html_body_sb.WriteString(
|
||||||
|
"<tr>" +
|
||||||
|
"<td style='text-align:center'> ... </td>" +
|
||||||
|
"<td style='text-align:center'> ... </td>" +
|
||||||
|
"<td style='text-align:center'> ... </td>" +
|
||||||
|
"</tr>")
|
||||||
|
}
|
||||||
|
|
||||||
|
text_body_sb.WriteString(tx.tx_type +
|
||||||
|
" " + strconv.Itoa(tx.tx_amount_msats/1000))
|
||||||
|
}
|
||||||
|
|
||||||
|
html_body_sb.WriteString("</table></body></html>")
|
||||||
|
|
||||||
|
html_body := html_body_sb.String()
|
||||||
|
text_body := text_body_sb.String()
|
||||||
|
|
||||||
|
send_email(recipient_email,
|
||||||
|
subject,
|
||||||
|
html_body,
|
||||||
|
text_body)
|
||||||
|
}
|
||||||
|
|
||||||
// https://docs.aws.amazon.com/sdk-for-go/v1/developer-guide/ses-example-send-email.html
|
// https://docs.aws.amazon.com/sdk-for-go/v1/developer-guide/ses-example-send-email.html
|
||||||
|
|
||||||
func send_email(recipient string, subject string, htmlBody string, textBody string) {
|
func send_email(recipient string, subject string, htmlBody string, textBody string) {
|
||||||
|
|
|
||||||
|
|
@ -182,7 +182,7 @@ func monitor_invoice_state(r_hash []byte) () {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
go send_email(c.email_address, "bolt card receipt", "html body", "text body")
|
go send_balance_email(c.email_address, card_id)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -278,7 +278,7 @@ func pay_invoice(card_payment_id int, invoice string) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
go send_email(c.email_address, "bolt card payment", "html body", "text body")
|
go send_balance_email(c.email_address, card_id)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,12 @@ import (
|
||||||
|
|
||||||
func lnurlw_callback(w http.ResponseWriter, req *http.Request) {
|
func lnurlw_callback(w http.ResponseWriter, req *http.Request) {
|
||||||
|
|
||||||
env_host_domain := os.Getenv("HOST_DOMAIN")
|
env_host_domain := os.Getenv("HOST_DOMAIN")
|
||||||
if req.Host != env_host_domain {
|
if req.Host != env_host_domain {
|
||||||
log.Warn("wrong host domain")
|
log.Warn("wrong host domain")
|
||||||
write_error(w)
|
write_error(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
url := req.URL.RequestURI()
|
url := req.URL.RequestURI()
|
||||||
log.WithFields(log.Fields{"url": url}).Debug("cb request")
|
log.WithFields(log.Fields{"url": url}).Debug("cb request")
|
||||||
|
|
@ -120,6 +120,24 @@ func lnurlw_callback(w http.ResponseWriter, req *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// check the card balance if marked as 'must stay above zero' (default)
|
||||||
|
// i.e. cards.allow_negative_balance == 'N'
|
||||||
|
|
||||||
|
if c.allow_negative_balance != "Y" {
|
||||||
|
card_total, err := db_get_card_total_sats(p.card_id)
|
||||||
|
if err != nil {
|
||||||
|
log.WithFields(log.Fields{"card_payment_id": p.card_payment_id}).Warn(err)
|
||||||
|
write_error(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if card_total-invoice_sats < 0 {
|
||||||
|
log.WithFields(log.Fields{"card_payment_id": p.card_payment_id}).Warn("not enough balance")
|
||||||
|
write_error(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log.WithFields(log.Fields{"card_payment_id": p.card_payment_id}).Info("paying invoice")
|
log.WithFields(log.Fields{"card_payment_id": p.card_payment_id}).Info("paying invoice")
|
||||||
|
|
||||||
// update paid_flag so we only attempt payment once
|
// update paid_flag so we only attempt payment once
|
||||||
|
|
@ -138,7 +156,7 @@ func lnurlw_callback(w http.ResponseWriter, req *http.Request) {
|
||||||
|
|
||||||
go pay_invoice(p.card_payment_id, param_pr)
|
go pay_invoice(p.card_payment_id, param_pr)
|
||||||
|
|
||||||
log.Debug("sending 'status OK' response");
|
log.Debug("sending 'status OK' response")
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue