diff --git a/finnow-api/.gitignore b/finnow-api/.gitignore index aa0d5b6..809bf80 100644 --- a/finnow-api/.gitignore +++ b/finnow-api/.gitignore @@ -14,3 +14,5 @@ finnow-api-test-* *.o *.obj *.lst + +users/ diff --git a/finnow-api/currency_codes_ISO4217.csv b/finnow-api/currency_codes_ISO4217.csv new file mode 100644 index 0000000..94e21b4 --- /dev/null +++ b/finnow-api/currency_codes_ISO4217.csv @@ -0,0 +1,442 @@ +Entity,Currency,AlphabeticCode,NumericCode,MinorUnit,WithdrawalDate +AFGHANISTAN,Afghani,AFN,971,2, +ÅLAND ISLANDS,Euro,EUR,978,2, +ALBANIA,Lek,ALL,008,2, +ALGERIA,Algerian Dinar,DZD,012,2, +AMERICAN SAMOA,US Dollar,USD,840,2, +ANDORRA,Euro,EUR,978,2, +ANGOLA,Kwanza,AOA,973,2, +ANGUILLA,East Caribbean Dollar,XCD,951,2, +ANTARCTICA,No universal currency,,,, +ANTIGUA AND BARBUDA,East Caribbean Dollar,XCD,951,2, +ARGENTINA,Argentine Peso,ARS,032,2, +ARMENIA,Armenian Dram,AMD,051,2, +ARUBA,Aruban Florin,AWG,533,2, +AUSTRALIA,Australian Dollar,AUD,036,2, +AUSTRIA,Euro,EUR,978,2, +AZERBAIJAN,Azerbaijan Manat,AZN,944,2, +BAHAMAS (THE),Bahamian Dollar,BSD,044,2, +BAHRAIN,Bahraini Dinar,BHD,048,3, +BANGLADESH,Taka,BDT,050,2, +BARBADOS,Barbados Dollar,BBD,052,2, +BELARUS,Belarusian Ruble,BYN,933,2, +BELGIUM,Euro,EUR,978,2, +BELIZE,Belize Dollar,BZD,084,2, +BENIN,CFA Franc BCEAO,XOF,952,0, +BERMUDA,Bermudian Dollar,BMD,060,2, +BHUTAN,Indian Rupee,INR,356,2, +BHUTAN,Ngultrum,BTN,064,2, +BOLIVIA (PLURINATIONAL STATE OF),Boliviano,BOB,068,2, +BOLIVIA (PLURINATIONAL STATE OF),Mvdol,BOV,984,2, +"BONAIRE, SINT EUSTATIUS AND SABA",US Dollar,USD,840,2, +BOSNIA AND HERZEGOVINA,Convertible Mark,BAM,977,2, +BOTSWANA,Pula,BWP,072,2, +BOUVET ISLAND,Norwegian Krone,NOK,578,2, +BRAZIL,Brazilian Real,BRL,986,2, +BRITISH INDIAN OCEAN TERRITORY (THE),US Dollar,USD,840,2, +BRUNEI DARUSSALAM,Brunei Dollar,BND,096,2, +BULGARIA,Bulgarian Lev,BGN,975,2, +BURKINA FASO,CFA Franc BCEAO,XOF,952,0, +BURUNDI,Burundi Franc,BIF,108,0, +CABO VERDE,Cabo Verde Escudo,CVE,132,2, +CAMBODIA,Riel,KHR,116,2, +CAMEROON,CFA Franc BEAC,XAF,950,0, +CANADA,Canadian Dollar,CAD,124,2, +CAYMAN ISLANDS (THE),Cayman Islands Dollar,KYD,136,2, +CENTRAL AFRICAN REPUBLIC (THE),CFA Franc BEAC,XAF,950,0, +CHAD,CFA Franc BEAC,XAF,950,0, +CHILE,Chilean Peso,CLP,152,0, +CHILE,Unidad de Fomento,CLF,990,4, +CHINA,Yuan Renminbi,CNY,156,2, +CHRISTMAS ISLAND,Australian Dollar,AUD,036,2, +COCOS (KEELING) ISLANDS (THE),Australian Dollar,AUD,036,2, +COLOMBIA,Colombian Peso,COP,170,2, +COLOMBIA,Unidad de Valor Real,COU,970,2, +COMOROS (THE),Comorian Franc ,KMF,174,0, +CONGO (THE DEMOCRATIC REPUBLIC OF THE),Congolese Franc,CDF,976,2, +CONGO (THE),CFA Franc BEAC,XAF,950,0, +COOK ISLANDS (THE),New Zealand Dollar,NZD,554,2, +COSTA RICA,Costa Rican Colon,CRC,188,2, +CÔTE D'IVOIRE,CFA Franc BCEAO,XOF,952,0, +CROATIA,Kuna,HRK,191,2, +CUBA,Cuban Peso,CUP,192,2, +CUBA,Peso Convertible,CUC,931,2, +CURAÇAO,Netherlands Antillean Guilder,ANG,532,2, +CYPRUS,Euro,EUR,978,2, +CZECHIA,Czech Koruna,CZK,203,2, +DENMARK,Danish Krone,DKK,208,2, +DJIBOUTI,Djibouti Franc,DJF,262,0, +DOMINICA,East Caribbean Dollar,XCD,951,2, +DOMINICAN REPUBLIC (THE),Dominican Peso,DOP,214,2, +ECUADOR,US Dollar,USD,840,2, +EGYPT,Egyptian Pound,EGP,818,2, +EL SALVADOR,El Salvador Colon,SVC,222,2, +EL SALVADOR,US Dollar,USD,840,2, +EQUATORIAL GUINEA,CFA Franc BEAC,XAF,950,0, +ERITREA,Nakfa,ERN,232,2, +ESTONIA,Euro,EUR,978,2, +ESWATINI,Lilangeni,SZL,748,2, +ETHIOPIA,Ethiopian Birr,ETB,230,2, +EUROPEAN UNION,Euro,EUR,978,2, +"FALKLAND ISLANDS (THE) [MALVINAS]",Falkland Islands Pound,FKP,238,2, +FAROE ISLANDS (THE),Danish Krone,DKK,208,2, +FIJI,Fiji Dollar,FJD,242,2, +FINLAND,Euro,EUR,978,2, +FRANCE,Euro,EUR,978,2, +FRENCH GUIANA,Euro,EUR,978,2, +FRENCH POLYNESIA,CFP Franc,XPF,953,0, +FRENCH SOUTHERN TERRITORIES (THE),Euro,EUR,978,2, +GABON,CFA Franc BEAC,XAF,950,0, +GAMBIA (THE),Dalasi,GMD,270,2, +GEORGIA,Lari,GEL,981,2, +GERMANY,Euro,EUR,978,2, +GHANA,Ghana Cedi,GHS,936,2, +GIBRALTAR,Gibraltar Pound,GIP,292,2, +GREECE,Euro,EUR,978,2, +GREENLAND,Danish Krone,DKK,208,2, +GRENADA,East Caribbean Dollar,XCD,951,2, +GUADELOUPE,Euro,EUR,978,2, +GUAM,US Dollar,USD,840,2, +GUATEMALA,Quetzal,GTQ,320,2, +GUERNSEY,Pound Sterling,GBP,826,2, +GUINEA,Guinean Franc,GNF,324,0, +GUINEA-BISSAU,CFA Franc BCEAO,XOF,952,0, +GUYANA,Guyana Dollar,GYD,328,2, +HAITI,Gourde,HTG,332,2, +HAITI,US Dollar,USD,840,2, +HEARD ISLAND AND McDONALD ISLANDS,Australian Dollar,AUD,036,2, +HOLY SEE (THE),Euro,EUR,978,2, +HONDURAS,Lempira,HNL,340,2, +HONG KONG,Hong Kong Dollar,HKD,344,2, +HUNGARY,Forint,HUF,348,2, +ICELAND,Iceland Krona,ISK,352,0, +INDIA,Indian Rupee,INR,356,2, +INDONESIA,Rupiah,IDR,360,2, +INTERNATIONAL MONETARY FUND (IMF),SDR (Special Drawing Right),XDR,960,-, +IRAN (ISLAMIC REPUBLIC OF),Iranian Rial,IRR,364,2, +IRAQ,Iraqi Dinar,IQD,368,3, +IRELAND,Euro,EUR,978,2, +ISLE OF MAN,Pound Sterling,GBP,826,2, +ISRAEL,New Israeli Sheqel,ILS,376,2, +ITALY,Euro,EUR,978,2, +JAMAICA,Jamaican Dollar,JMD,388,2, +JAPAN,Yen,JPY,392,0, +JERSEY,Pound Sterling,GBP,826,2, +JORDAN,Jordanian Dinar,JOD,400,3, +KAZAKHSTAN,Tenge,KZT,398,2, +KENYA,Kenyan Shilling,KES,404,2, +KIRIBATI,Australian Dollar,AUD,036,2, +KOREA (THE DEMOCRATIC PEOPLE'S REPUBLIC OF),North Korean Won,KPW,408,2, +KOREA (THE REPUBLIC OF),Won,KRW,410,0, +KUWAIT,Kuwaiti Dinar,KWD,414,3, +KYRGYZSTAN,Som,KGS,417,2, +LAO PEOPLE'S DEMOCRATIC REPUBLIC (THE),Lao Kip,LAK,418,2, +LATVIA,Euro,EUR,978,2, +LEBANON,Lebanese Pound,LBP,422,2, +LESOTHO,Loti,LSL,426,2, +LESOTHO,Rand,ZAR,710,2, +LIBERIA,Liberian Dollar,LRD,430,2, +LIBYA,Libyan Dinar,LYD,434,3, +LIECHTENSTEIN,Swiss Franc,CHF,756,2, +LITHUANIA,Euro,EUR,978,2, +LUXEMBOURG,Euro,EUR,978,2, +MACAO,Pataca,MOP,446,2, +NORTH MACEDONIA,Denar,MKD,807,2, +MADAGASCAR,Malagasy Ariary,MGA,969,2, +MALAWI,Malawi Kwacha,MWK,454,2, +MALAYSIA,Malaysian Ringgit,MYR,458,2, +MALDIVES,Rufiyaa,MVR,462,2, +MALI,CFA Franc BCEAO,XOF,952,0, +MALTA,Euro,EUR,978,2, +MARSHALL ISLANDS (THE),US Dollar,USD,840,2, +MARTINIQUE,Euro,EUR,978,2, +MAURITANIA,Ouguiya,MRU,929,2, +MAURITIUS,Mauritius Rupee,MUR,480,2, +MAYOTTE,Euro,EUR,978,2, +MEMBER COUNTRIES OF THE AFRICAN DEVELOPMENT BANK GROUP,ADB Unit of Account,XUA,965,-, +MEXICO,Mexican Peso,MXN,484,2, +MEXICO,Mexican Unidad de Inversion (UDI),MXV,979,2, +MICRONESIA (FEDERATED STATES OF),US Dollar,USD,840,2, +MOLDOVA (THE REPUBLIC OF),Moldovan Leu,MDL,498,2, +MONACO,Euro,EUR,978,2, +MONGOLIA,Tugrik,MNT,496,2, +MONTENEGRO,Euro,EUR,978,2, +MONTSERRAT,East Caribbean Dollar,XCD,951,2, +MOROCCO,Moroccan Dirham,MAD,504,2, +MOZAMBIQUE,Mozambique Metical,MZN,943,2, +MYANMAR,Kyat,MMK,104,2, +NAMIBIA,Namibia Dollar,NAD,516,2, +NAMIBIA,Rand,ZAR,710,2, +NAURU,Australian Dollar,AUD,036,2, +NEPAL,Nepalese Rupee,NPR,524,2, +NETHERLANDS (THE),Euro,EUR,978,2, +NEW CALEDONIA,CFP Franc,XPF,953,0, +NEW ZEALAND,New Zealand Dollar,NZD,554,2, +NICARAGUA,Cordoba Oro,NIO,558,2, +NIGER (THE),CFA Franc BCEAO,XOF,952,0, +NIGERIA,Naira,NGN,566,2, +NIUE,New Zealand Dollar,NZD,554,2, +NORFOLK ISLAND,Australian Dollar,AUD,036,2, +NORTHERN MARIANA ISLANDS (THE),US Dollar,USD,840,2, +NORWAY,Norwegian Krone,NOK,578,2, +OMAN,Rial Omani,OMR,512,3, +PAKISTAN,Pakistan Rupee,PKR,586,2, +PALAU,US Dollar,USD,840,2, +"PALESTINE, STATE OF",No universal currency,,,, +PANAMA,Balboa,PAB,590,2, +PANAMA,US Dollar,USD,840,2, +PAPUA NEW GUINEA,Kina,PGK,598,2, +PARAGUAY,Guarani,PYG,600,0, +PERU,Sol,PEN,604,2, +PHILIPPINES (THE),Philippine Peso,PHP,608,2, +PITCAIRN,New Zealand Dollar,NZD,554,2, +POLAND,Zloty,PLN,985,2, +PORTUGAL,Euro,EUR,978,2, +PUERTO RICO,US Dollar,USD,840,2, +QATAR,Qatari Rial,QAR,634,2, +RÉUNION,Euro,EUR,978,2, +ROMANIA,Romanian Leu,RON,946,2, +RUSSIAN FEDERATION (THE),Russian Ruble,RUB,643,2, +RWANDA,Rwanda Franc,RWF,646,0, +SAINT BARTHÉLEMY,Euro,EUR,978,2, +"SAINT HELENA, ASCENSION AND TRISTAN DA CUNHA",Saint Helena Pound,SHP,654,2, +SAINT KITTS AND NEVIS,East Caribbean Dollar,XCD,951,2, +SAINT LUCIA,East Caribbean Dollar,XCD,951,2, +SAINT MARTIN (FRENCH PART),Euro,EUR,978,2, +SAINT PIERRE AND MIQUELON,Euro,EUR,978,2, +SAINT VINCENT AND THE GRENADINES,East Caribbean Dollar,XCD,951,2, +SAMOA,Tala,WST,882,2, +SAN MARINO,Euro,EUR,978,2, +SAO TOME AND PRINCIPE,Dobra,STN,930,2, +SAUDI ARABIA,Saudi Riyal,SAR,682,2, +SENEGAL,CFA Franc BCEAO,XOF,952,0, +SERBIA,Serbian Dinar,RSD,941,2, +SEYCHELLES,Seychelles Rupee,SCR,690,2, +SIERRA LEONE,Leone,SLL,694,2, +SINGAPORE,Singapore Dollar,SGD,702,2, +SINT MAARTEN (DUTCH PART),Netherlands Antillean Guilder,ANG,532,2, +"SISTEMA UNITARIO DE COMPENSACION REGIONAL DE PAGOS ""SUCRE""",Sucre,XSU,994,-, +SLOVAKIA,Euro,EUR,978,2, +SLOVENIA,Euro,EUR,978,2, +SOLOMON ISLANDS,Solomon Islands Dollar,SBD,090,2, +SOMALIA,Somali Shilling,SOS,706,2, +SOUTH AFRICA,Rand,ZAR,710,2, +SOUTH GEORGIA AND THE SOUTH SANDWICH ISLANDS,No universal currency,,,, +SOUTH SUDAN,South Sudanese Pound,SSP,728,2, +SPAIN,Euro,EUR,978,2, +SRI LANKA,Sri Lanka Rupee,LKR,144,2, +SUDAN (THE),Sudanese Pound,SDG,938,2, +SURINAME,Surinam Dollar,SRD,968,2, +SVALBARD AND JAN MAYEN,Norwegian Krone,NOK,578,2, +SWEDEN,Swedish Krona,SEK,752,2, +SWITZERLAND,Swiss Franc,CHF,756,2, +SWITZERLAND,WIR Euro,CHE,947,2, +SWITZERLAND,WIR Franc,CHW,948,2, +SYRIAN ARAB REPUBLIC,Syrian Pound,SYP,760,2, +TAIWAN (PROVINCE OF CHINA),New Taiwan Dollar,TWD,901,2, +TAJIKISTAN,Somoni,TJS,972,2, +"TANZANIA, UNITED REPUBLIC OF",Tanzanian Shilling,TZS,834,2, +THAILAND,Baht,THB,764,2, +TIMOR-LESTE,US Dollar,USD,840,2, +TOGO,CFA Franc BCEAO,XOF,952,0, +TOKELAU,New Zealand Dollar,NZD,554,2, +TONGA,Pa'anga,TOP,776,2, +TRINIDAD AND TOBAGO,Trinidad and Tobago Dollar,TTD,780,2, +TUNISIA,Tunisian Dinar,TND,788,3, +TURKEY,Turkish Lira,TRY,949,2, +TURKMENISTAN,Turkmenistan New Manat,TMT,934,2, +TURKS AND CAICOS ISLANDS (THE),US Dollar,USD,840,2, +TUVALU,Australian Dollar,AUD,036,2, +UGANDA,Uganda Shilling,UGX,800,0, +UKRAINE,Hryvnia,UAH,980,2, +UNITED ARAB EMIRATES (THE),UAE Dirham,AED,784,2, +UNITED KINGDOM OF GREAT BRITAIN AND NORTHERN IRELAND (THE),Pound Sterling,GBP,826,2, +UNITED STATES MINOR OUTLYING ISLANDS (THE),US Dollar,USD,840,2, +UNITED STATES OF AMERICA (THE),US Dollar,USD,840,2, +UNITED STATES OF AMERICA (THE),US Dollar (Next day),USN,997,2, +URUGUAY,Peso Uruguayo,UYU,858,2, +URUGUAY,Uruguay Peso en Unidades Indexadas (UI),UYI,940,0, +URUGUAY,Unidad Previsional,UYW,927,4, +UZBEKISTAN,Uzbekistan Sum,UZS,860,2, +VANUATU,Vatu,VUV,548,0, +VENEZUELA (BOLIVARIAN REPUBLIC OF),Bolívar Soberano,VES,928,2, +VIET NAM,Dong,VND,704,0, +VIRGIN ISLANDS (BRITISH),US Dollar,USD,840,2, +VIRGIN ISLANDS (U.S.),US Dollar,USD,840,2, +WALLIS AND FUTUNA,CFP Franc,XPF,953,0, +WESTERN SAHARA,Moroccan Dirham,MAD,504,2, +YEMEN,Yemeni Rial,YER,886,2, +ZAMBIA,Zambian Kwacha,ZMW,967,2, +ZIMBABWE,Zimbabwe Dollar,ZWL,932,2, +ZZ01_Bond Markets Unit European_EURCO,Bond Markets Unit European Composite Unit (EURCO),XBA,955,-, +ZZ02_Bond Markets Unit European_EMU-6,Bond Markets Unit European Monetary Unit (E.M.U.-6),XBB,956,-, +ZZ03_Bond Markets Unit European_EUA-9,Bond Markets Unit European Unit of Account 9 (E.U.A.-9),XBC,957,-, +ZZ04_Bond Markets Unit European_EUA-17,Bond Markets Unit European Unit of Account 17 (E.U.A.-17),XBD,958,-, +ZZ06_Testing_Code,Codes specifically reserved for testing purposes,XTS,963,-, +ZZ07_No_Currency,The codes assigned for transactions where no currency is involved,XXX,999,-, +ZZ08_Gold,Gold,XAU,959,-, +ZZ09_Palladium,Palladium,XPD,964,-, +ZZ10_Platinum,Platinum,XPT,962,-, +ZZ11_Silver,Silver,XAG,961,-, +AFGHANISTAN,Afghani,AFA,004,,2003-01 +ÅLAND ISLANDS,Markka,FIM,246,,2002-03 +ALBANIA,Old Lek,ALK,008,,1989-12 +ANDORRA,Andorran Peseta,ADP,020,,2003-07 +ANDORRA,Spanish Peseta,ESP,724,,2002-03 +ANDORRA,French Franc,FRF,250,,2002-03 +ANGOLA,Kwanza,AOK,024,,1991-03 +ANGOLA,New Kwanza,AON,024,,2000-02 +ANGOLA,Kwanza Reajustado,AOR,982,,2000-02 +ARGENTINA,Austral,ARA,032,,1992-01 +ARGENTINA,Peso Argentino,ARP,032,,1985-07 +ARGENTINA,Peso,ARY,032,,1989 to 1990 +ARMENIA,Russian Ruble,RUR,810,,1994-08 +AUSTRIA,Schilling,ATS,040,,2002-03 +AZERBAIJAN,Azerbaijan Manat,AYM,945,,2005-10 +AZERBAIJAN,Azerbaijanian Manat,AZM,031,,2005-12 +AZERBAIJAN,Russian Ruble,RUR,810,,1994-08 +BELARUS,Belarusian Ruble,BYB,112,,2001-01 +BELARUS,Belarusian Ruble,BYR,974,,2017-01 +BELARUS,Russian Ruble,RUR,810,,1994-06 +BELGIUM,Convertible Franc,BEC,993,,1990-03 +BELGIUM,Belgian Franc,BEF,056,,2002-03 +BELGIUM,Financial Franc,BEL,992,,1990-03 +BOLIVIA,Peso boliviano,BOP,068,,1987-02 +BOSNIA AND HERZEGOVINA,Dinar,BAD,070,,1998-07 +BRAZIL,Cruzeiro,BRB,076,,1986-03 +BRAZIL,Cruzado,BRC,076,,1989-02 +BRAZIL,Cruzeiro,BRE,076,,1993-03 +BRAZIL,New Cruzado,BRN,076,,1990-03 +BRAZIL,Cruzeiro Real,BRR,987,,1994-07 +BULGARIA,Lev A/52,BGJ,100,,1989 to 1990 +BULGARIA,Lev A/62,BGK,100,,1989 to 1990 +BULGARIA,Lev,BGL,100,,2003-11 +BURMA,Kyat,BUK,104,,1990-02 +CROATIA,Croatian Dinar,HRD,191,,1995-01 +CROATIA,Croatian Kuna,HRK,191,,2015-06 +CYPRUS,Cyprus Pound,CYP,196,,2008-01 +CZECHOSLOVAKIA,Krona A/53,CSJ,203,,1989 to 1990 +CZECHOSLOVAKIA,Koruna,CSK,200,,1993-03 +ECUADOR,Sucre,ECS,218,,2000-09 +ECUADOR,Unidad de Valor Constante (UVC),ECV,983,,2000-09 +EQUATORIAL GUINEA,Ekwele,GQE,226,,1986-06 +ESTONIA,Kroon,EEK,233,,2011-01 +EUROPEAN MONETARY CO-OPERATION FUND (EMCF),European Currency Unit (E.C.U),XEU,954,,1999-01 +FINLAND,Markka,FIM,246,,2002-03 +FRANCE,French Franc,FRF,250,,2002-03 +FRENCH GUIANA,French Franc,FRF,250,,2002-03 +FRENCH SOUTHERN TERRITORIES,French Franc,FRF,250,,2002-03 +GEORGIA,Georgian Coupon,GEK,268,,1995-10 +GEORGIA,Russian Ruble,RUR,810,,1994-04 +GERMAN DEMOCRATIC REPUBLIC,Mark der DDR,DDM,278,,1990-07 to 1990-09 +GERMANY,Deutsche Mark,DEM,276,,2002-03 +GHANA,Cedi,GHC,288,,2008-01 +GHANA,Ghana Cedi,GHP,939,,2007-06 +GREECE,Drachma,GRD,300,,2002-03 +GUADELOUPE,French Franc,FRF,250,,2002-03 +GUINEA,Syli,GNE,324,,1989-12 +GUINEA,Syli,GNS,324,,1986-02 +GUINEA-BISSAU,Guinea Escudo,GWE,624,,1978 to 1981 +GUINEA-BISSAU,Guinea-Bissau Peso,GWP,624,,1997-05 +HOLY SEE (VATICAN CITY STATE),Italian Lira,ITL,380,,2002-03 +ICELAND,Old Krona,ISJ,352,,1989 to 1990 +IRELAND,Irish Pound,IEP,372,,2002-03 +ISRAEL,Pound,ILP,376,,1978 to 1981 +ISRAEL,Old Shekel,ILR,376,,1989 to 1990 +ITALY,Italian Lira,ITL,380,,2002-03 +KAZAKHSTAN,Russian Ruble,RUR,810,,1994-05 +KYRGYZSTAN,Russian Ruble,RUR,810,,1993-01 +LAO,Pathet Lao Kip,LAJ,418,,1979-12 +LATVIA,Latvian Lats,LVL,428,,2014-01 +LATVIA,Latvian Ruble,LVR,428,,1994-12 +LESOTHO,Loti,LSM,426,,1985-05 +LESOTHO,Financial Rand,ZAL,991,,1995-03 +LITHUANIA,Lithuanian Litas,LTL,440,,2014-12 +LITHUANIA,Talonas,LTT,440,,1993-07 +LUXEMBOURG,Luxembourg Convertible Franc,LUC,989,,1990-03 +LUXEMBOURG,Luxembourg Franc,LUF,442,,2002-03 +LUXEMBOURG,Luxembourg Financial Franc,LUL,988,,1990-03 +MADAGASCAR,Malagasy Franc,MGF,450,,2004-12 +MALAWI,Kwacha,MWK,454,,2016-02 +MALDIVES,Maldive Rupee,MVQ,462,,1989-12 +MALI,Mali Franc,MLF,466,,1984-11 +MALTA,Maltese Lira,MTL,470,,2008-01 +MALTA,Maltese Pound,MTP,470,,1983-06 +MARTINIQUE,French Franc,FRF,250,,2002-03 +MAURITANIA,Ouguiya,MRO,478,,2017-12 +MAYOTTE,French Franc,FRF,250,,2002-03 +MEXICO,Mexican Peso,MXP,484,,1993-01 +"MOLDOVA, REPUBLIC OF",Russian Ruble,RUR,810,,1993-12 +MONACO,French Franc,FRF,250,,2002-03 +MOZAMBIQUE,Mozambique Escudo,MZE,508,,1978 to 1981 +MOZAMBIQUE,Mozambique Metical,MZM,508,,2006-06 +NETHERLANDS,Netherlands Guilder,NLG,528,,2002-03 +NETHERLANDS ANTILLES,Netherlands Antillean Guilder,ANG,532,,2010-10 +NICARAGUA,Cordoba,NIC,558,,1990-10 +PERU,Sol,PEH,604,,1989 to 1990 +PERU,Inti,PEI,604,,1991-07 +PERU,Nuevo Sol ,PEN,604,,2015-12 +PERU,Sol,PES,604,,1986-02 +POLAND,Zloty,PLZ,616,,1997-01 +PORTUGAL,Portuguese Escudo,PTE,620,,2002-03 +RÉUNION,French Franc,FRF,250,,2002-03 +ROMANIA,Leu A/52,ROK,642,,1989 to 1990 +ROMANIA,Old Leu,ROL,642,,2005-06 +ROMANIA,New Romanian Leu ,RON,946,,2015-06 +RUSSIAN FEDERATION,Russian Ruble,RUR,810,,2004-01 +SAINT MARTIN,French Franc,FRF,250,,1999-01 +SAINT PIERRE AND MIQUELON,French Franc,FRF,250,,2002-03 +SAINT-BARTHÉLEMY,French Franc,FRF,250,,1999-01 +SAN MARINO,Italian Lira,ITL,380,,2002-03 +SAO TOME AND PRINCIPE,Dobra,STD,678,,2017-12 +SERBIA AND MONTENEGRO,Serbian Dinar,CSD,891,,2006-10 +SERBIA AND MONTENEGRO,Euro,EUR,978,,2006-10 +SLOVAKIA,Slovak Koruna,SKK,703,,2009-01 +SLOVENIA,Tolar,SIT,705,,2007-01 +SOUTH AFRICA,Financial Rand,ZAL,991,,1995-03 +SOUTH SUDAN,Sudanese Pound,SDG,938,,2012-09 +SOUTHERN RHODESIA,Rhodesian Dollar,RHD,716,,1978 to 1981 +SPAIN,Spanish Peseta,ESA,996,,1978 to 1981 +SPAIN,"""A"" Account (convertible Peseta Account)",ESB,995,,1994-12 +SPAIN,Spanish Peseta,ESP,724,,2002-03 +SUDAN,Sudanese Dinar,SDD,736,,2007-07 +SUDAN,Sudanese Pound,SDP,736,,1998-06 +SURINAME,Surinam Guilder,SRG,740,,2003-12 +SWAZILAND,Lilangeni,SZL,748,,2018-08 +SWITZERLAND,WIR Franc (for electronic),CHC,948,,2004-11 +TAJIKISTAN,Russian Ruble,RUR,810,,1995-05 +TAJIKISTAN,Tajik Ruble,TJR,762,,2001-04 +TIMOR-LESTE,Rupiah,IDR,360,,2002-07 +TIMOR-LESTE,Timor Escudo,TPE,626,,2002-11 +TURKEY,Old Turkish Lira,TRL,792,,2005-12 +TURKEY,New Turkish Lira,TRY,949,,2009-01 +TURKMENISTAN,Russian Ruble,RUR,810,,1993-10 +TURKMENISTAN,Turkmenistan Manat,TMM,795,,2009-01 +UGANDA,Uganda Shilling,UGS,800,,1987-05 +UGANDA,Old Shilling,UGW,800,,1989 to 1990 +UKRAINE,Karbovanet,UAK,804,,1996-09 +UNION OF SOVIET SOCIALIST REPUBLICS,Rouble,SUR,810,,1990-12 +UNITED STATES,US Dollar (Same day),USS,998,,2014-03 +URUGUAY,Old Uruguay Peso,UYN,858,,1989-12 +URUGUAY,Uruguayan Peso,UYP,858,,1993-03 +UZBEKISTAN,Russian Ruble,RUR,810,,1994-07 +VENEZUELA,Bolivar,VEB,862,,2008-01 +VENEZUELA,Bolivar Fuerte,VEF,937,,2011-12 +VENEZUELA (BOLIVARIAN REPUBLIC OF),Bolivar,VEF,937,,2016-02 +VENEZUELA (BOLIVARIAN REPUBLIC OF),Bolívar,VEF,937,,2018-08 +VIETNAM,Old Dong,VNC,704,,1989-1990 +"YEMEN, DEMOCRATIC",Yemeni Dinar,YDD,720,,1991-09 +YUGOSLAVIA,New Yugoslavian Dinar,YUD,890,,1990-01 +YUGOSLAVIA,New Dinar,YUM,891,,2003-07 +YUGOSLAVIA,Yugoslavian Dinar,YUN,890,,1995-11 +ZAIRE,New Zaire,ZRN,180,,1999-06 +ZAIRE,Zaire,ZRZ,180,,1994-02 +ZAMBIA,Zambian Kwacha,ZMK,894,,2012-12 +ZIMBABWE,Rhodesian Dollar,ZWC,716,,1989-12 +ZIMBABWE,Zimbabwe Dollar (old),ZWD,716,,2006-08 +ZIMBABWE,Zimbabwe Dollar,ZWD,716,,2008-08 +ZIMBABWE,Zimbabwe Dollar (new),ZWN,942,,2006-09 +ZIMBABWE,Zimbabwe Dollar,ZWR,935,,2009-06 +ZZ01_Gold-Franc,Gold-Franc,XFO,,,2006-10 +ZZ02_RINET Funds Code,RINET Funds Code,XRE,,,1999-11 +ZZ05_UIC-Franc,UIC-Franc,XFU,,,2013-11 diff --git a/finnow-api/dub.json b/finnow-api/dub.json index 52952ca..2d60fa4 100644 --- a/finnow-api/dub.json +++ b/finnow-api/dub.json @@ -4,9 +4,20 @@ ], "copyright": "Copyright © 2024, Andrew Lalis", "dependencies": { - "handy-httpd": "~>8.4.0" + "asdf": "~>0.7.17", + "botan": "~>1.13.6", + "d2sqlite3": "~>1.0.0", + "handy-httpd": "~>8.4.0", + "jwt": "~>0.4.0", + "slf4d": "~>3.0.1" }, "description": "Backend API for Finnow.", "license": "proprietary", - "name": "finnow-api" + "name": "finnow-api", + "stringImportPaths": [ + "." + ], + "subConfigurations": { + "d2sqlite3": "all-included" + } } \ No newline at end of file diff --git a/finnow-api/dub.selections.json b/finnow-api/dub.selections.json index 07cab5f..25b6df6 100644 --- a/finnow-api/dub.selections.json +++ b/finnow-api/dub.selections.json @@ -1,9 +1,18 @@ { "fileVersion": 1, "versions": { + "asdf": "0.7.17", + "botan": "1.13.6", + "botan-math": "1.0.4", + "d2sqlite3": "1.0.0", "handy-httpd": "8.4.0", "httparsed": "1.2.1", + "jwt": "0.4.0", + "memutils": "1.0.10", + "mir-algorithm": "3.22.1", + "mir-core": "1.7.1", "path-matcher": "1.2.0", + "silly": "1.1.1", "slf4d": "3.0.1", "streams": "3.5.0" } diff --git a/finnow-api/schema.sql b/finnow-api/schema.sql new file mode 100644 index 0000000..636249d --- /dev/null +++ b/finnow-api/schema.sql @@ -0,0 +1,80 @@ +-- This schema is included at compile-time into data : SqliteDataSource. + +-- Basic/Utility Entities + +CREATE TABLE profile_property ( + property TEXT PRIMARY KEY, + value TEXT DEFAULT NULL +); + +CREATE TABLE attachment ( + id INTEGER PRIMARY KEY, + uploaded_at TEXT NOT NULL, + filename TEXT NOT NULL, + content_type TEXT NOT NULL, + size INTEGER NOT NULL, + content BLOB NOT NULL +); + +-- Account Entities + +CREATE TABLE account ( + id INTEGER PRIMARY KEY, + created_at TEXT NOT NULL, + archived BOOLEAN NOT NULL DEFAULT FALSE, + type TEXT NOT NULL, + number_suffix TEXT, + name TEXT NOT NULL, + currency TEXT NOT NULL, + description TEXT +); + +CREATE TABLE account_credit_card_properties ( + account_id INTEGER PRIMARY KEY, + credit_limit TEXT, + CONSTRAINT fk_account_credit_card_properties_account + FOREIGN KEY (account_id) REFERENCES account(id) + ON UPDATE CASCADE ON DELETE CASCADE +); + +-- Transaction Entities + +CREATE TABLE transaction_vendor ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT +); + +CREATE TABLE transaction_category ( + id INTEGER PRIMARY KEY, + parent_id INTEGER, + name TEXT NOT NULL UNIQUE, + color TEXT NOT NULL DEFAULT 'FFFFFF', + CONSTRAINT fk_transaction_category_parent + FOREIGN KEY (parent_id) REFERENCES transaction_category(id) + ON UPDATE CASCADE ON DELETE CASCADE +); + +CREATE TABLE transaction_tag ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE +); + +CREATE TABLE "transaction" ( + id INTEGER PRIMARY KEY, + timestamp TEXT NOT NULL, + added_at TEXT NOT NULL, + amount TEXT NOT NULL, + currency TEXT NOT NULL, + description TEXT, + vendor_id INTEGER, + category_id INTEGER, + CONSTRAINT fk_transaction_vendor + FOREIGN KEY (vendor_id) REFERENCES transaction_vendor(id) + ON UPDATE CASCADE ON DELETE SET NULL, + CONSTRAINT fk_transaction_category + FOREIGN KEY (category_id) REFERENCES transaction_category(id) + ON UPDATE CASCADE ON DELETE SET NULL +); + + diff --git a/finnow-api/source/app.d b/finnow-api/source/app.d index e5600ec..0c59fa1 100644 --- a/finnow-api/source/app.d +++ b/finnow-api/source/app.d @@ -1,17 +1,50 @@ import slf4d; import handy_httpd; import handy_httpd.handlers.path_handler; - -import model.base; +import handy_httpd.handlers.filtered_handler; void main() { ServerConfig cfg; cfg.workerPoolSize = 5; cfg.port = 8080; - PathHandler pathHandler = new PathHandler(); - pathHandler.addMapping(Method.GET, "/status", (ref ctx) { - ctx.response.writeBodyString("online"); - }); - HttpServer server = new HttpServer(pathHandler, cfg); + HttpServer server = new HttpServer(buildHandlers(), cfg); server.start(); } + +PathHandler buildHandlers() { + import profile; + + const API_PATH = "/api"; + PathHandler pathHandler = new PathHandler(); + + // Generic, public endpoints: + pathHandler.addMapping(Method.GET, API_PATH ~ "/status", (ref ctx) { + ctx.response.writeBodyString("online"); + }); + pathHandler.addMapping(Method.OPTIONS, API_PATH ~ "/**", (ref ctx) {}); + + // Auth Entrypoints: + import auth.api; + import auth.service; + pathHandler.addMapping(Method.POST, API_PATH ~ "/login", &postLogin); + pathHandler.addMapping(Method.POST, API_PATH ~ "/register", &postRegister); + + // Authenticated endpoints: + PathHandler a = new PathHandler(); + a.addMapping(Method.GET, API_PATH ~ "/me", &getMyUser); + a.addMapping(Method.GET, API_PATH ~ "/profiles", &handleGetProfiles); + a.addMapping(Method.POST, API_PATH ~ "/profiles", &handleCreateNewProfile); + a.addMapping(Method.DELETE, API_PATH ~ "/profiles/:name", &handleDeleteProfile); + a.addMapping(Method.GET, API_PATH ~ "/profiles/:profile/properties", &handleGetProperties); + a.addMapping(Method.GET, API_PATH ~ "/profiles/:profile/accounts", (ref ctx) { + ctx.response.writeBodyString("your accounts!"); + }); + + HttpRequestFilter tokenAuthenticationFilter = new TokenAuthenticationFilter(SECRET); + pathHandler.addMapping(API_PATH ~ "/**", new FilteredRequestHandler( + a, + [tokenAuthenticationFilter] + )); + + return pathHandler; +} diff --git a/finnow-api/source/auth/api.d b/finnow-api/source/auth/api.d new file mode 100644 index 0000000..0806db1 --- /dev/null +++ b/finnow-api/source/auth/api.d @@ -0,0 +1,76 @@ +/// API endpoints for authentication-related functions, like registration and login. +module auth.api; + +import handy_httpd; +import handy_httpd.components.optional; +import slf4d; + +import auth.model; +import auth.dao; +import auth.dto; +import auth.service; + +void postLogin(ref HttpRequestContext ctx) { + LoginCredentials loginCredentials; + try { + loginCredentials = LoginCredentials.parse(ctx.request.readBodyAsJson()); + } catch (Exception e) { + ctx.response.status = HttpStatus.BAD_REQUEST; + } + if (!validateUsername(loginCredentials.username)) { + ctx.response.status = HttpStatus.UNAUTHORIZED; + return; + } + UserRepository userRepo = new FileSystemUserRepository(); + Optional!User optionalUser = userRepo.findByUsername(loginCredentials.username); + if (optionalUser.isNull) { + ctx.response.status = HttpStatus.UNAUTHORIZED; + return; + } + import botan.passhash.bcrypt : checkBcrypt; + if (!checkBcrypt(loginCredentials.password, optionalUser.value.passwordHash)) { + ctx.response.status = HttpStatus.UNAUTHORIZED; + return; + } + string token = generateAccessToken(optionalUser.value); + ctx.response.status = HttpStatus.OK; + ctx.response.writeBodyString(TokenResponse(token).toJson(), "application/json"); +} + +void postRegister(ref HttpRequestContext ctx) { + RegistrationData registrationData; + try { + registrationData = RegistrationData.parse(ctx.request.readBodyAsJson()); + } catch (Exception e) { + ctx.response.status = HttpStatus.BAD_REQUEST; + return; + } + if (!validateUsername(registrationData.username)) { + ctx.response.status = HttpStatus.BAD_REQUEST; + ctx.response.writeBodyString("Invalid username."); + return; + } + if (!validatePassword(registrationData.password)) { + ctx.response.status = HttpStatus.BAD_REQUEST; + ctx.response.writeBodyString("Invalid password."); + return; + } + UserRepository userRepo = new FileSystemUserRepository(); + if (!userRepo.findByUsername(registrationData.username).isNull) { + ctx.response.status = HttpStatus.BAD_REQUEST; + ctx.response.writeBodyString("Username is taken."); + return; + } + + import botan.passhash.bcrypt : generateBcrypt; + import botan.rng.auto_rng; + RandomNumberGenerator rng = new AutoSeededRNG(); + string passwordHash = generateBcrypt(registrationData.password, rng, 12); + userRepo.createUser(registrationData.username, passwordHash); + infoF!"Created user: %s"(registrationData.username); +} + +void getMyUser(ref HttpRequestContext ctx) { + AuthContext auth = getAuthContext(ctx); + ctx.response.writeBodyString(auth.user.username); +} diff --git a/finnow-api/source/auth/dao.d b/finnow-api/source/auth/dao.d new file mode 100644 index 0000000..c10e224 --- /dev/null +++ b/finnow-api/source/auth/dao.d @@ -0,0 +1,66 @@ +module auth.dao; + +import handy_httpd.components.optional; +import auth.model; + +interface UserRepository { + Optional!User findByUsername(string username); + User createUser(string username, string passwordHash); + void deleteByUsername(string username); +} + +/** + * User implementation that stores each user's data in a separate directory. + */ +class FileSystemUserRepository : UserRepository { + import std.path; + import std.file; + import std.json; + + private string usersDir; + + this(string usersDir = "users") { + this.usersDir = usersDir; + } + + Optional!User findByUsername(string username) { + if ( + !validateUsername(username) || + !exists(getUserDir(username)) || + !exists(getUserDataFile(username)) + ) { + return Optional!User.empty; + } + JSONValue userObj = parseJSON(readText(getUserDataFile(username))); + return Optional!User.of(User( + username, + userObj.object["passwordHash"].str + )); + } + + User createUser(string username, string passwordHash) { + if (!validateUsername(username)) throw new Exception("Invalid username"); + if (exists(getUserDir(username))) throw new Exception("User already exists."); + JSONValue userObj = JSONValue.emptyObject; + userObj.object["passwordHash"] = JSONValue(passwordHash); + string jsonStr = userObj.toPrettyString(); + if (!exists(this.usersDir)) mkdir(this.usersDir); + mkdir(getUserDir(username)); + std.file.write(getUserDataFile(username), cast(ubyte[]) jsonStr); + return User(username, passwordHash); + } + + void deleteByUsername(string username) { + if (validateUsername(username) && exists(getUserDir(username))) { + rmdirRecurse(getUserDir(username)); + } + } + + private string getUserDir(string username) { + return buildPath(this.usersDir, username); + } + + private string getUserDataFile(string username) { + return buildPath(this.usersDir, username, "user-data.json"); + } +} diff --git a/finnow-api/source/auth/dto.d b/finnow-api/source/auth/dto.d new file mode 100644 index 0000000..798fbf2 --- /dev/null +++ b/finnow-api/source/auth/dto.d @@ -0,0 +1,46 @@ +/// Defines data-transfer objects for the API's authentication mechanisms. +module auth.dto; + +import handy_httpd; +import std.json; + +struct LoginCredentials { + string username; + string password; + + static LoginCredentials parse(JSONValue obj) { + if ( + obj.type != JSONType.OBJECT || + "username" !in obj.object || + "password" !in obj.object || + obj.object["username"].type != JSONType.STRING || + obj.object["password"].type != JSONType.STRING + ) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Malformed login credentials."); + } + return LoginCredentials( + obj.object["username"].str, + obj.object["password"].str + ); + } +} + +struct TokenResponse { + string token; + + string toJson() { + JSONValue obj = JSONValue.emptyObject; + obj.object["token"] = JSONValue(token); + return obj.toString(); + } +} + +struct RegistrationData { + string username; + string password; + + static RegistrationData parse(JSONValue obj) { + LoginCredentials lc = LoginCredentials.parse(obj); + return RegistrationData(lc.username, lc.password); + } +} \ No newline at end of file diff --git a/finnow-api/source/auth/model.d b/finnow-api/source/auth/model.d new file mode 100644 index 0000000..739873d --- /dev/null +++ b/finnow-api/source/auth/model.d @@ -0,0 +1,41 @@ +/** + * Defines models for the Finnow API's authentication system. + */ +module auth.model; + +/** + * The user is the basic authenticated entity representing someone who has one + * or more profiles. + */ +struct User { + const string username; + const string passwordHash; +} + +/** + * Validates a username string. + * Params: + * username = The username to check. + * Returns: True if the username is valid. + */ +bool validateUsername(string username) { + import std.regex; + import std.uni : toLower; + if (username is null || username.length < 3) return false; + const string[] RESERVED_USERNAMES = ["user", "admin"]; + static foreach (reservedUsername; RESERVED_USERNAMES) { + if (toLower(username) == reservedUsername) return false; + } + auto r = ctRegex!(`^[a-zA-Z]+[a-zA-Z0-9_]+$`); + return !matchFirst(username, r).empty; +} + +/** + * Validates that a password is of sufficient complexity. + * Params: + * password = The password to check. + * Returns: True if the password is sufficiently complex. + */ +bool validatePassword(string password) { + return password !is null && password.length >= 8; +} diff --git a/finnow-api/source/auth/service.d b/finnow-api/source/auth/service.d new file mode 100644 index 0000000..c26ab48 --- /dev/null +++ b/finnow-api/source/auth/service.d @@ -0,0 +1,108 @@ +module auth.service; + +import handy_httpd; +import handy_httpd.components.optional; +import slf4d; + +import auth.model; +import auth.dao; +import handy_httpd.handlers.filtered_handler; + +const SECRET = "temporary-insecure-secret"; // TODO: Load secret from application config! + +/** + * Generates a new JWT access token for a user. + * Params: + * user = The user to generate the token for. + * Returns: The token. + */ +string generateAccessToken(User user) { + import jwt.jwt : Token; + import jwt.algorithms : JWTAlgorithm; + import std.datetime; + const TIMEOUT_MINUTES = 30; + Token token = new Token(JWTAlgorithm.HS512); + token.claims.aud("finnow-api"); + token.claims.sub(user.username); + token.claims.exp(Clock.currTime().toUnixTime() + TIMEOUT_MINUTES * 60); + token.claims.iss("finnow-api"); + return token.encode(SECRET); +} + +/** + * A request filter that only permits authenticated requests to be processed. + */ +class TokenAuthenticationFilter : HttpRequestFilter { + private static const AUTH_METADATA_KEY = "AuthContext"; + private immutable string secret; + + this(string secret) { + this.secret = secret; + } + + void apply(ref HttpRequestContext ctx, FilterChain fc) { + Optional!AuthContext optionalAuth = validateAuthContext(ctx); + if (!optionalAuth.isNull) { + ctx.metadata[AUTH_METADATA_KEY] = optionalAuth.value; + fc.doFilter(ctx); // Only continue the filter chain if a valid auth context was obtained. + } + } +} + +/// Information about the current request's authentication status. +class AuthContext { + string token; + User user; + this(string token, User user) { + this.token = token; + this.user = user; + } +} + +/** + * Helper method to get the authentication context from a request context + * that was previously passed through this filter. + * Params: + * ctx = The request context to get. + * Returns: The auth context that has been set. + */ +AuthContext getAuthContext(ref HttpRequestContext ctx) { + return cast(AuthContext) ctx.metadata[TokenAuthenticationFilter.AUTH_METADATA_KEY]; +} + +private Optional!AuthContext validateAuthContext(ref HttpRequestContext ctx) { + import jwt.jwt : verify, Token; + import jwt.algorithms : JWTAlgorithm; + import std.typecons; + + const HEADER_NAME = "Authorization"; + if (!ctx.request.headers.contains(HEADER_NAME)) { + return setUnauthorized(ctx, "Missing Authorization header."); + } + + string authorizationHeader = ctx.request.headers.getFirst(HEADER_NAME).orElse(""); + if (authorizationHeader.length < 7 || authorizationHeader[0..7] != "Bearer ") { + return setUnauthorized(ctx, "Invalid Authorization header format. Expected bearer token."); + } + + string rawToken = authorizationHeader[7..$]; + try { + Token token = verify(rawToken, SECRET, [JWTAlgorithm.HS512]); + string username = token.claims.sub; + UserRepository userRepo = new FileSystemUserRepository(); + Optional!User optionalUser = userRepo.findByUsername(username); + if (optionalUser.isNull) { + return setUnauthorized(ctx, "User does not exist."); + } + return Optional!AuthContext.of(new AuthContext(rawToken, optionalUser.value)); + } catch (Exception e) { + warn("Failed to verify user token.", e); + return setUnauthorized(ctx, "Invalid or malformed token."); + } +} + +private Optional!AuthContext setUnauthorized(ref HttpRequestContext ctx, string msg) { + ctx.response.status = HttpStatus.UNAUTHORIZED; + ctx.response.writeBodyString(msg); + return Optional!AuthContext.empty; +} diff --git a/finnow-api/source/data/account.d b/finnow-api/source/data/account.d new file mode 100644 index 0000000..0ee7170 --- /dev/null +++ b/finnow-api/source/data/account.d @@ -0,0 +1,2 @@ +module data.account; + diff --git a/finnow-api/source/data/base.d b/finnow-api/source/data/base.d new file mode 100644 index 0000000..248adfd --- /dev/null +++ b/finnow-api/source/data/base.d @@ -0,0 +1,15 @@ +module data.base; + +import model; +import handy_httpd.components.optional; + +interface DataSource { + PropertiesRepository getPropertiesRepository(); +} + +interface PropertiesRepository { + Optional!string findProperty(string propertyName); + void setProperty(string name, string value); + void deleteProperty(string name); + ProfileProperty[] findAll(); +} diff --git a/finnow-api/source/data/package.d b/finnow-api/source/data/package.d new file mode 100644 index 0000000..597bcbd --- /dev/null +++ b/finnow-api/source/data/package.d @@ -0,0 +1,6 @@ +module data; + +public import data.base; + +// Utility imports for items commonly used alongside data. +public import handy_httpd.components.optional; diff --git a/finnow-api/source/data/sqlite.d b/finnow-api/source/data/sqlite.d new file mode 100644 index 0000000..92e56b7 --- /dev/null +++ b/finnow-api/source/data/sqlite.d @@ -0,0 +1,82 @@ +module data.sqlite; + +import d2sqlite3; +import slf4d; +import data.base; +import model; + +class SqliteDataSource : DataSource { + const SCHEMA = import("schema.sql"); + + private const string dbPath; + private Database db; + + this(string path) { + this.dbPath = path; + import std.file : exists; + bool needsInit = !exists(path); + this.db = Database(path); + if (needsInit) { + infoF!"Initializing database: %s"(dbPath); + db.run(SCHEMA); + } + } + + PropertiesRepository getPropertiesRepository() { + return new SqlitePropertiesRepository(db); + } +} + +class SqliteRepository { + private Database db; + this(Database db) { + this.db = db; + } +} + +class SqlitePropertiesRepository : SqliteRepository, PropertiesRepository { + this(Database db) { + super(db); + } + + Optional!string findProperty(string propertyName) { + Statement stmt = this.db.prepare("SELECT value FROM profile_property WHERE property = ?"); + stmt.bind(1, propertyName); + ResultRange result = stmt.execute(); + if (result.empty) return Optional!string.empty; + return Optional!string.of(result.front.peek!string(0)); + } + + void setProperty(string name, string value) { + if (findProperty(name).isNull) { + Statement stmt = this.db.prepare("INSERT INTO profile_property (property, value) VALUES (?, ?)"); + stmt.bind(1, name); + stmt.bind(2, value); + stmt.execute(); + } else { + Statement stmt = this.db.prepare("UPDATE profile_property SET value = ? WHERE property = ?"); + stmt.bind(1, value); + stmt.bind(2, name); + stmt.execute(); + } + } + + void deleteProperty(string name) { + Statement stmt = this.db.prepare("DELETE FROM profile_property WHERE property = ?"); + stmt.bind(1, name); + stmt.execute(); + } + + ProfileProperty[] findAll() { + Statement stmt = this.db.prepare("SELECT * FROM profile_property ORDER BY property ASC"); + ResultRange result = stmt.execute(); + ProfileProperty[] props; + foreach (Row row; result) { + ProfileProperty prop; + prop.property = row.peek!string("property"); + prop.value = row.peek!string("value"); + props ~= prop; + } + return props; + } +} diff --git a/finnow-api/source/model/account.d b/finnow-api/source/model/account.d new file mode 100644 index 0000000..3b6fd8e --- /dev/null +++ b/finnow-api/source/model/account.d @@ -0,0 +1,20 @@ +module model.account; + +import model.base; +import std.datetime; + +struct Account { + ulong id; + SysTime createdAt; + bool archived; + string type; + string numberSuffix; + string name; + string currency; + string description; +} + +struct AccountCreditCardProperties { + ulong account_id; + string creditLimit; +} diff --git a/finnow-api/source/model/base.d b/finnow-api/source/model/base.d index d5c12ce..b0f033e 100644 --- a/finnow-api/source/model/base.d +++ b/finnow-api/source/model/base.d @@ -1,37 +1,17 @@ module model.base; -/** - * The base class for all persistent entities with a unique integer id. - * It offers some basic utilities like equality and comparison by default. - */ -abstract class IdEntity { - const ulong id; +import std.datetime; - this(ulong id) { - this.id = id; - } +struct ProfileProperty { + string property; + string value; +} - override bool opEquals(Object other) const { - if (IdEntity e = cast(IdEntity) other) { - return e.id == id; - } - return false; - } - - override size_t toHash() const { - import std.conv : to; - return id.to!size_t; - } - - override string toString() const { - import std.format : format; - return format!"IdEntity(id = %d)"(id); - } - - override int opCmp(Object other) const { - if (IdEntity e = cast(IdEntity) other) { - return this.id < e.id; - } - return 1; - } -} \ No newline at end of file +struct Attachment { + ulong id; + SysTime uploadedAt; + string filename; + string contentType; + ulong size; + ubyte[] content; +} diff --git a/finnow-api/source/model/currency.d b/finnow-api/source/model/currency.d new file mode 100644 index 0000000..8d9ba75 --- /dev/null +++ b/finnow-api/source/model/currency.d @@ -0,0 +1,33 @@ +module model.currency; + +struct Currency { + const char[3] code; + const ubyte fractionalDigits; + const ushort numericCode; + + import std.traits : isSomeString, EnumMembers; + static Currency ofCode(S)(S code) if (isSomeString!S) { + if (code.length != 3) { + throw new Exception("Invalid currency code: " ~ code); + } + static foreach (c; EnumMembers!Currencies) { + if (c.code == code) return c; + } + throw new Exception("Unknown currency code: " ~ code); + } +} + +enum Currencies : Currency { + USD = Currency("USD", 2, 840), + CAD = Currency("CAD", 2, 124), + GBP = Currency("GBP", 2, 826), + EUR = Currency("EUR", 2, 978), + CHF = Currency("CHF", 2, 756), + ZAR = Currency("ZAR", 2, 710), + JPY = Currency("JPY", 0, 392), + INR = Currency("INR", 2, 356) +} + +unittest { + assert(Currency.ofCode("USD") == Currencies.USD); +} diff --git a/finnow-api/source/model/package.d b/finnow-api/source/model/package.d new file mode 100644 index 0000000..fbc32a3 --- /dev/null +++ b/finnow-api/source/model/package.d @@ -0,0 +1,8 @@ +module model; + +public import model.base; +public import model.account; +public import model.transaction; + +// Additional utility imports used often alongside models. +public import handy_httpd.components.optional; \ No newline at end of file diff --git a/finnow-api/source/model/transaction.d b/finnow-api/source/model/transaction.d new file mode 100644 index 0000000..a065bf7 --- /dev/null +++ b/finnow-api/source/model/transaction.d @@ -0,0 +1,33 @@ +module model.transaction; + +import std.datetime; +import model.currency; + +struct TransactionVendor { + ulong id; + string name; + string description; +} + +struct TransactionCategory { + ulong id; + ulong parentId; + string name; + string color; +} + +struct TransactionTag { + ulong id; + string name; +} + +struct Transaction { + ulong id; + SysTime timestamp; + SysTime addedAt; + string amount; + Currency currency; + string description; + ulong vendorId; + ulong categoryId; +} diff --git a/finnow-api/source/profile.d b/finnow-api/source/profile.d new file mode 100644 index 0000000..a763e2b --- /dev/null +++ b/finnow-api/source/profile.d @@ -0,0 +1,214 @@ +/** + * This module contains everything related to a user's Profiles, including + * data repositories, API endpoints, models, and logic. + */ +module profile; + +import handy_httpd; +import handy_httpd.components.optional; +import auth.model : User; +import auth.service : AuthContext, getAuthContext; +import data; +import model; + +import std.json; +import asdf; + +const DEFAULT_USERS_DIR = "users"; + +/** + * A profile is a complete set of Finnow financial data, all stored in one + * single database file. The profile's name is used to lookup the database + * partition for its data. A user may own multiple profiles. + */ +class Profile { + string name; + + this(string name) { + this.name = name; + } + + override int opCmp(Object other) const { + if (Profile p = cast(Profile) other) { + return this.name < p.name; + } + return 1; + } +} + +/** + * Validates a profile name. + * Params: + * name = The name to check. + * Returns: True if the profile name is valid. + */ +bool validateProfileName(string name) { + import std.regex; + import std.uni : toLower; + if (name is null || name.length < 3) return false; + auto r = ctRegex!(`^[a-zA-Z]+[a-zA-Z0-9_]+$`); + return !matchFirst(name, r).empty; +} + +interface ProfileRepository { + Optional!Profile findByName(string name); + Profile createProfile(string name); + Profile[] findAll(); + void deleteByName(string name); + DataSource getDataSource(in Profile profile); +} + +class FileSystemProfileRepository : ProfileRepository { + import std.path; + import std.file; + import data; + import data.sqlite; + + private const string usersDir; + private const string username; + + this(string usersDir, string username) { + this.usersDir = usersDir; + this.username = username; + } + + this(string username) { + this(DEFAULT_USERS_DIR, username); + } + + Optional!Profile findByName(string name) { + string path = getProfilePath(name); + if (!exists(path)) return Optional!Profile.empty; + return Optional!Profile.of(new Profile(name)); + } + + Profile createProfile(string name) { + string path = getProfilePath(name); + if (exists(path)) throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Profile already exists."); + if (!exists(getProfilesDir())) mkdir(getProfilesDir()); + DataSource ds = new SqliteDataSource(path); + import std.datetime; + auto propsRepo = ds.getPropertiesRepository(); + propsRepo.setProperty("name", name); + propsRepo.setProperty("createdAt", Clock.currTime(UTC()).toISOExtString()); + propsRepo.setProperty("user", username); + return new Profile(name); + } + + Profile[] findAll() { + string profilesDir = getProfilesDir(); + if (!exists(profilesDir)) return []; + Profile[] profiles; + foreach (DirEntry entry; dirEntries(profilesDir, SpanMode.shallow, false)) { + import std.string : endsWith; + const suffix = ".sqlite"; + if (endsWith(entry.name, suffix)) { + string profileName = baseName(entry.name, suffix); + profiles ~= new Profile(profileName); + } + } + import std.algorithm.sorting : sort; + sort(profiles); + return profiles; + } + + void deleteByName(string name) { + string path = getProfilePath(name); + if (exists(path)) { + std.file.remove(path); + } + } + + DataSource getDataSource(in Profile profile) { + return new SqliteDataSource(getProfilePath(profile.name)); + } + + private string getProfilesDir() { + return buildPath(this.usersDir, username, "profiles"); + } + + private string getProfilePath(string name) { + return buildPath(this.usersDir, username, "profiles", name ~ ".sqlite"); + } +} + +// API Endpoints Below Here! + +void handleCreateNewProfile(ref HttpRequestContext ctx) { + JSONValue obj = ctx.request.readBodyAsJson(); + string name = obj.object["name"].str; + if (!validateProfileName(name)) { + ctx.response.status = HttpStatus.BAD_REQUEST; + ctx.response.writeBodyString("Invalid profile name."); + return; + } + AuthContext auth = getAuthContext(ctx); + ProfileRepository profileRepo = new FileSystemProfileRepository(auth.user.username); + profileRepo.createProfile(name); +} + +void handleGetProfiles(ref HttpRequestContext ctx) { + AuthContext auth = getAuthContext(ctx); + ProfileRepository profileRepo = new FileSystemProfileRepository(auth.user.username); + Profile[] profiles = profileRepo.findAll(); + ctx.response.writeBodyString(serializeToJson(profiles), "application/json"); +} + +void handleDeleteProfile(ref HttpRequestContext ctx) { + string name = ctx.request.getPathParamAs!string("name"); + if (!validateProfileName(name)) { + ctx.response.status = HttpStatus.BAD_REQUEST; + ctx.response.writeBodyString("Invalid profile name."); + return; + } + AuthContext auth = getAuthContext(ctx); + ProfileRepository profileRepo = new FileSystemProfileRepository(auth.user.username); + profileRepo.deleteByName(name); +} + +void handleGetProperties(ref HttpRequestContext ctx) { + ProfileContext profileCtx = getProfileContextOrThrow(ctx); + ProfileRepository profileRepo = new FileSystemProfileRepository(profileCtx.user.username); + DataSource ds = profileRepo.getDataSource(profileCtx.profile); + auto propsRepo = ds.getPropertiesRepository(); + ProfileProperty[] props = propsRepo.findAll(); + ctx.response.writeBodyString(serializeToJson(props), "application/json"); +} + +/// Contextual information that's available when handling requests under a profile. +struct ProfileContext { + const Profile profile; + const User user; +} + +/** + * Tries to get a profile context from a request context. This will attempt to + * extract a "profile" path parameter and the authenticated user, and combine + * them into the ProfileContext. + * Params: + * ctx = The request context to read. + * Returns: An optional profile context. + */ +Optional!ProfileContext getProfileContext(ref HttpRequestContext ctx) { + import auth.service : AuthContext, getAuthContext; + if ("profile" !in ctx.request.pathParams) return Optional!ProfileContext.empty; + string profileName = ctx.request.pathParams["profile"]; + if (!validateProfileName(profileName)) return Optional!ProfileContext.empty; + AuthContext authCtx = getAuthContext(ctx); + if (authCtx is null) return Optional!ProfileContext.empty; + User user = authCtx.user; + ProfileRepository repo = new FileSystemProfileRepository("users", user.username); + return repo.findByName(profileName) + .mapIfPresent!(p => ProfileContext(p, user)); +} + +/** + * Similar to `getProfileContext`, but throws an HttpStatusException with a + * 404 NOT FOUND status if no profile context could be obtained. + * Params: + * ctx = The request context to read. + * Returns: The profile context that was obtained. + */ +ProfileContext getProfileContextOrThrow(ref HttpRequestContext ctx) { + return getProfileContext(ctx).orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND)); +}