Ghost

struxadotcloud/struxa

public
Fork0
CodeIssues0Pull requests0
1branch
struxaappswebsrcappapibillingwebhook[provider]route.ts
feat: global unslop6531421a month ago171 lines · 6.3 KB
1import type { NextRequest } from "next/server";
2import { and, eq
3import {
4 db,
5 billingPaymentGateways,
6 billingReferralRedemptions,
7 billingWallet,
8 billingWalletTransactions,
9 billingTransactions,
10 settings,
11} from "@struxa/db";
12import { decrypt }
13import { notifyAdmins }
14import { handleWebhook }
15import type { WebhookPayload
16import { randomUUID }
17
18async function addWalletCredits(
19 tx: Parameters
20 userId: string,
21 amountCents: number,
22 currency: string,
23 description: string,
24) {
25 const walletRow =
26 let balanceBefore =
27 if (walletRow) {
28 balanceBefore = walletRow.
29 await tx.update
30 } else {
31 await tx.insert
32 }
33 await tx.insert
34 id: randomUUID(),
35 userId,
36 amountCents,
37 balanceAfterCents: balanceBefore +
38 currency,
39 type: "adjustment",
40 description,
41 });
42}
43
44async function creditWallet(
45 try {
46 await db.transaction
47 const walletRow =
48 const currency =
49 let balanceBefore =
50
51 if (walletRow) {
52 balanceBefore = walletRow.
53 await tx.update
54 } else {
55 await tx.insert
56 }
57
58 const balanceAfter =
59
60 await tx.insert
61 id: randomUUID(),
62 userId: payload.userId
63 amountCents: payload.amountCents
64 balanceAfterCents: balanceAfter,
65 currency,
66 type: "topup",
67 description: `wallet_topup:${gatewayProvider
68 });
69
70 // Insert last - unique constraint on providerTransactionId prevents double-processing
71 await tx.insert
72 id: randomUUID(),
73 userId: payload.userId
74 gatewayId: payload.gatewayId
75 type: "payment",
76 status: "succeeded",
77 amountCents: payload.amountCents
78 currency,
79 providerTransactionId: payload.providerTransactionId
80 });
81
82 const redemption =
83 where: and(
84 eq(billingReferralRedemptions.refereeId
85 isNull(billingReferralRedemptions.firstTopupAt
86 ),
87 });
88
89 if (redemption) {
90 const settingsRows =
91 const s =
92 if (s.billing_referral_enabled
93
94 const refereeDiscountPercent =
95 const referrerRewardPercent =
96
97 const claimed =
98 .update(billingReferralRedemptions)
99 .set({ firstTopupAt:
100 .where(and(
101 eq(billingReferralRedemptions.id
102 isNull(billingReferralRedemptions.firstTopupAt
103 ));
104 if ((claimed as
105
106 if (refereeDiscountPercent >
107 const bonusCents =
108 await addWalletCredits(tx
109 }
110
111 if (referrerRewardPercent >
112 const rewardCents =
113 await addWalletCredits(tx
114 }
115 }
116 });
117 return true;
118 } catch (err)
119 const sqlErr =
120 // Unique constraint violation = already processed this webhook
121 if (sqlErr?.code
122 throw err;
123 }
124}
125
126export async function POST
127 req: NextRequest
128 { params }:
129) {
130 const { provider
131 const rawBody =
132 const headers =
133
134 const gateway =
135 where: and(
136 eq(billingPaymentGateways.provider
137 eq(billingPaymentGateways.isActive
138 ),
139 });
140
141 if (!gateway)
142
143 const raw =
144 publishableKey?: string
145 } | null ??
146
147 const sourceIp =
148 ?? req.headers.
149 ?? undefined;
150
151 const config =
152 publishableKey: raw.publishableKey
153 secretKey: raw.secretKey
154 webhookSecret: raw.webhookSecret
155 serviceId: raw.serviceId
156 sandbox: raw.sandbox
157 };
158
159 const payload =
160
161 if (payload?.type
162 const inserted =
163 if (inserted) {
164 notifyAdmins("payment",
165 }
166 } else if (payload
167 notifyAdmins("payment",
168 }
169
170 return new Response
171}
,
isNull
}
from
"
drizzle-orm
"
;
from
"
@struxa/api/lib/crypto
"
;
from
"
@struxa/api/services/notifications
"
;
from
"
@struxa/payments
"
;
}
from
"
@struxa/payments
"
;
from
"
crypto
"
;
<
Parameters
<
typeof
db
.
transaction
>
[
0
]
>
[
0
]
,
await
tx
.
query
.
billingWallet
.
findFirst
(
{
where
:
eq
(
billingWallet
.
userId
,
userId
)
}
)
;
0
;
balanceCents
;
(
billingWallet
)
.
set
(
{
balanceCents
:
walletRow
.
balanceCents
+
amountCents
}
)
.
where
(
eq
(
billingWallet
.
userId
,
userId
))
;
(
billingWallet
)
.
values
(
{
id
:
randomUUID
()
,
userId
,
balanceCents
:
amountCents
,
currency
}
)
;
(
billingWalletTransactions
)
.
values
(
{
amountCents
,
payload
:
Extract
<
WebhookPayload
,
{
type
:
"
topup.succeeded
"
}
>
,
gatewayProvider
:
string
)
:
Promise
<
boolean
>
{
(
async
(
tx
)
=>
{
await
tx
.
query
.
billingWallet
.
findFirst
(
{
where
:
eq
(
billingWallet
.
userId
,
payload
.
userId
)
}
)
;
payload
.
currency
.
toUpperCase
()
;
0
;
balanceCents
;
(
billingWallet
)
.
set
(
{
balanceCents
:
walletRow
.
balanceCents
+
payload
.
amountCents
}
)
.
where
(
eq
(
billingWallet
.
userId
,
payload
.
userId
))
;
(
billingWallet
)
.
values
(
{
id
:
randomUUID
()
,
userId
:
payload
.
userId
,
balanceCents
:
payload
.
amountCents
,
currency
}
)
;
balanceBefore
+
payload
.
amountCents
;
(
billingWalletTransactions
)
.
values
(
{
,
,
}
`
,
(
billingTransactions
)
.
values
(
{
,
,
,
,
await
tx
.
query
.
billingReferralRedemptions
.
findFirst
(
{
,
payload
.
userId
)
,
)
,
await
tx
.
select
()
.
from
(
settings
)
;
Object
.
fromEntries
(
settingsRows
.
map
(
(
r
)
=>
[
r
.
key
,
r
.
value
??
""
]))
;
!==
"
true
"
)
return
;
Number
(
s
.
billing_referral_referee_discount_percent
)
||
0
;
Number
(
s
.
billing_referral_referrer_reward_percent
)
||
0
;
await
tx
new
Date
()
}
)
,
redemption
.
id
)
,
)
,
unknown
as
[
{
affectedRows
:
number
}
]
)[
0
]
.
affectedRows
!==
1
)
return
;
0
)
{
Math
.
round
(
payload
.
amountCents
*
refereeDiscountPercent
/
100
)
;
,
payload
.
userId
,
bonusCents
,
currency
,
"
referral_bonus
"
)
;
0
)
{
Math
.
round
(
payload
.
amountCents
*
referrerRewardPercent
/
100
)
;
,
redemption
.
referrerId
,
rewardCents
,
currency
,
"
referral_commission
"
)
;
{
err
as
{
code
?
:
string
}
;
===
"
ER_DUP_ENTRY
"
)
return
false
;
(
,
{
params
:
Promise
<
{
provider
:
string
}
>
}
,
}
=
await
params
;
await
req
.
text
()
;
Object
.
fromEntries
(
req
.
headers
.
entries
())
;
await
db
.
query
.
billingPaymentGateways
.
findFirst
(
{
,
provider
)
,
,
true
)
,
return
new
Response
(
"
Not found
"
,
{
status
:
404
}
)
;
gateway
.
config
as
{
;
secretKey
?
:
string
;
webhookSecret
?
:
string
;
serviceId
?
:
string
;
sandbox
?
:
string
;
{}
;
req
.
headers
.
get
(
"
x-forwarded-for
"
)
?.
split
(
"
,
"
)[
0
]
?.
trim
()
get
(
"
x-real-ip
"
)
{
??
""
,
?
decrypt
(
raw
.
secretKey
)
:
""
,
?
decrypt
(
raw
.
webhookSecret
)
:
""
,
,
===
"
true
"
,
await
handleWebhook
(
provider
,
{
rawBody
,
headers
,
config
,
gatewayId
:
gateway
.
id
,
sourceIp
}
)
;
===
"
topup.succeeded
"
)
{
await
creditWallet
(
payload
,
gateway
.
provider
)
;
{
result
:
"
received
"
,
amount
:
(
payload
.
amountCents
/
100
)
.
toFixed
(
2
)
,
currency
:
payload
.
currency
.
toUpperCase
()
,
provider
:
gateway
.
provider
}
)
;
?.
type
===
"
topup.failed
"
)
{
{
result
:
"
failed
"
,
provider
:
gateway
.
provider
}
)
;
(
"
OK
"
,
{
status
:
200
}
)
;