Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: CI
on:
push:
branches: [ main ]
pull_request:
jobs:
flutter:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: subosito/flutter-action@v2
with:
channel: stable
- run: flutter --version
- run: flutter pub get
working-directory: android-client
- run: flutter test
working-directory: android-client
go:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- run: go test ./...
working-directory: wallet-plugin-go
18 changes: 3 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,4 @@
# opencongopay
# Decentralized Payments

A new Flutter project.

## Getting Started

This project is a starting point for a Flutter application.

A few resources to get you started if this is your first Flutter project:

- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)

For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
Ce monorepo regroupe l'application Flutter et le backend wallet en Go.
Le plugin `walletd` expose des APIs REST pour crediter un compte via les SMS Mobile Money.
File renamed without changes.
File renamed without changes.
16 changes: 16 additions & 0 deletions android-client/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# opencongopay

A new Flutter project.

## Getting Started

This project is a starting point for a Flutter application.

A few resources to get you started if this is your first Flutter project:

- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)

For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
1 change: 1 addition & 0 deletions docs/en/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
English docs coming soon
File renamed without changes.
58 changes: 58 additions & 0 deletions docs/fr/contrat-api.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
openapi: 3.1.0
info:
title: API Wallet
version: "1.0"
paths:
/api/credits:
post:
summary: Crediter un numero
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
phone:
type: string
amount:
type: integer
timestamp:
type: string
raw_sms:
type: string
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
properties:
id:
type: integer
/wallet/{phone}:
get:
summary: Obtenir le solde
parameters:
- in: path
name: phone
required: true
schema:
type: string
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
properties:
balance:
type: integer
/healthz:
get:
summary: Sante du service
responses:
'200':
description: OK
3 changes: 3 additions & 0 deletions docs/fr/installation-android.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Installation Android

Decrivez comment installer l'application Flutter.
3 changes: 3 additions & 0 deletions docs/fr/installation-server.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Installation du serveur

Guide pour deployer walletd via Docker.
3 changes: 3 additions & 0 deletions docs/fr/parsers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Formats SMS Operateurs

Liste des regex pour parser les SMS.
62 changes: 62 additions & 0 deletions wallet-plugin-go/cmd/server/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package main

import (
"encoding/json"
"log"
"net/http"
"time"

"github.com/go-chi/chi/v5"
_ "github.com/mattn/go-sqlite3"

"github.com/example/wallet-plugin-go/internal/wallet"
)

func main() {
store, err := wallet.NewStore("wallet.db")
if err != nil {
log.Fatal(err)
}

r := chi.NewRouter()
r.Post("/api/credits", func(w http.ResponseWriter, r *http.Request) {
var body struct {
Phone string `json:"phone"`
Amount int64 `json:"amount"`
Timestamp time.Time `json:"timestamp"`
RawSMS string `json:"raw_sms"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
id, err := store.AddCredit(wallet.Transaction{
Phone: body.Phone,
Amount: body.Amount,
RawSMS: body.RawSMS,
Timestamp: body.Timestamp,
})
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"id": id})
})
r.Get("/wallet/{phone}", func(w http.ResponseWriter, r *http.Request) {
phone := chi.URLParam(r, "phone")
bal, err := store.Balance(phone)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"balance": bal})
})
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})

log.Println("listening on :8080")
http.ListenAndServe(":8080", r)
}
8 changes: 8 additions & 0 deletions wallet-plugin-go/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module github.com/example/wallet-plugin-go

go 1.22

require (
github.com/go-chi/chi/v5 v5.0.8
github.com/mattn/go-sqlite3 v1.14.18
)
4 changes: 4 additions & 0 deletions wallet-plugin-go/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
github.com/go-chi/chi/v5 v5.0.8 h1:lD+NLqFcAi1ovnVZpsnObHGW4xb4J8lNmoYVfECH1Y0=
github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=
github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
96 changes: 96 additions & 0 deletions wallet-plugin-go/internal/wallet/store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package wallet

import (
"database/sql"
"errors"
"regexp"
"strconv"
"strings"
"time"

_ "github.com/mattn/go-sqlite3"
)

type Store struct {
db *sql.DB
}

type Transaction struct {
ID int64
Phone string
Amount int64
RawSMS string
Timestamp time.Time
}

var amountRx = regexp.MustCompile(`([0-9]+)`)

func NewStore(path string) (*Store, error) {
db, err := sql.Open("sqlite3", path)
if err != nil {
return nil, err
}
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS transactions(
id INTEGER PRIMARY KEY AUTOINCREMENT,
phone TEXT NOT NULL,
amount INTEGER NOT NULL,
raw_sms TEXT NOT NULL,
ts DATETIME NOT NULL
)`); err != nil {
return nil, err
}
return &Store{db: db}, nil
}

func parseAmount(raw string) (int64, error) {
match := amountRx.FindString(raw)
if match == "" {
return 0, errors.New("amount not found")
}
return strconv.ParseInt(match, 10, 64)
}

func (s *Store) AddCredit(txn Transaction) (int64, error) {
if txn.Phone == "" || txn.Amount <= 0 || txn.RawSMS == "" {
return 0, errors.New("invalid transaction")
}

parsed, err := parseAmount(strings.ReplaceAll(txn.RawSMS, " ", ""))
if err != nil {
return 0, err
}
if parsed != txn.Amount {
return 0, errors.New("amount mismatch")
}

res, err := s.db.Exec(`INSERT INTO transactions(phone,amount,raw_sms,ts) VALUES(?,?,?,?)`,
txn.Phone, txn.Amount, txn.RawSMS, txn.Timestamp)
if err != nil {
return 0, err
}
return res.LastInsertId()
}

func (s *Store) Balance(phone string) (int64, error) {
var bal sql.NullInt64
err := s.db.QueryRow(`SELECT COALESCE(SUM(amount),0) FROM transactions WHERE phone=?`, phone).Scan(&bal)
if err != nil {
return 0, err
}
if !bal.Valid {
return 0, nil
}
return bal.Int64, nil
}

func (s *Store) LastTransaction(phone string) (*Transaction, error) {
row := s.db.QueryRow(`SELECT id, phone, amount, raw_sms, ts FROM transactions WHERE phone=? ORDER BY id DESC LIMIT 1`, phone)
var t Transaction
if err := row.Scan(&t.ID, &t.Phone, &t.Amount, &t.RawSMS, &t.Timestamp); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &t, nil
}
50 changes: 50 additions & 0 deletions wallet-plugin-go/internal/wallet/store_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package wallet

import (
"path/filepath"
"testing"
"time"
)

func TestAddCreditAndBalance(t *testing.T) {
dir := t.TempDir()
store, err := NewStore(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}

sms := "Transfert de 2000 CDF"
txn := Transaction{Phone: "+243810000111", Amount: 2000, RawSMS: sms, Timestamp: time.Now()}
if _, err := store.AddCredit(txn); err != nil {
t.Fatalf("add credit: %v", err)
}

bal, err := store.Balance(txn.Phone)
if err != nil {
t.Fatalf("balance: %v", err)
}
if bal != 2000 {
t.Fatalf("got balance %d", bal)
}

last, err := store.LastTransaction(txn.Phone)
if err != nil {
t.Fatalf("last tx: %v", err)
}
if last == nil || last.Amount != 2000 {
t.Fatalf("unexpected last tx: %#v", last)
}
}

func TestAmountMismatch(t *testing.T) {
dir := t.TempDir()
store, err := NewStore(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
sms := "Transfert de 1000 CDF"
txn := Transaction{Phone: "123", Amount: 2000, RawSMS: sms, Timestamp: time.Now()}
if _, err := store.AddCredit(txn); err == nil {
t.Fatal("expected error on mismatch")
}
}
Loading