Skip to content

Commit c582f61

Browse files
Pricing update 2025 - Part 1
2 parents 629e943 + 9c59d49 commit c582f61

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

53 files changed

+3394
-2393
lines changed
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
package cmdpricing
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/spf13/cobra"
8+
"github.com/stripe/stripe-go/v72"
9+
"github.com/stripe/stripe-go/v72/client"
10+
11+
portalcmd "github.com/authgear/authgear-server/cmd/portal/cmd"
12+
portalconfig "github.com/authgear/authgear-server/pkg/portal/config"
13+
"github.com/authgear/authgear-server/pkg/util/cobrasentry"
14+
)
15+
16+
var Dollar int64 = 100
17+
var Cent int64 = 1
18+
19+
func createPlanProduct(api *client.API, logger Logger, planName string, planDisplayName string, price int64) error {
20+
params := &stripe.PriceParams{
21+
Currency: stripe.String(string(stripe.CurrencyUSD)),
22+
UnitAmount: stripe.Int64(price),
23+
TaxBehavior: stripe.String(string(stripe.PriceTaxBehaviorInclusive)),
24+
Recurring: &stripe.PriceRecurringParams{
25+
Interval: stripe.String(string(stripe.PriceRecurringIntervalMonth)),
26+
},
27+
ProductData: &stripe.PriceProductDataParams{
28+
Name: stripe.String(planDisplayName),
29+
Metadata: map[string]string{
30+
"plan_name": planName,
31+
"price_type": "fixed",
32+
"subscription_item_type": "plan",
33+
"version": "2025",
34+
},
35+
},
36+
}
37+
result, err := api.Prices.New(params)
38+
if err != nil {
39+
return err
40+
}
41+
42+
updateProductParams := &stripe.ProductParams{
43+
DefaultPrice: stripe.String(result.ID),
44+
}
45+
_, err = api.Products.Update(result.Product.ID, updateProductParams)
46+
if err != nil {
47+
return err
48+
}
49+
50+
logger.
51+
WithField("name", planDisplayName).
52+
WithField("product_id", result.Product.ID).
53+
Info("Created product")
54+
return nil
55+
}
56+
57+
func createSMSProduct(api *client.API, logger Logger, displayName, itemType string, smsRegion string, perUnitPrice int64) error {
58+
params := &stripe.PriceParams{
59+
Currency: stripe.String(string(stripe.CurrencyUSD)),
60+
TaxBehavior: stripe.String(string(stripe.PriceTaxBehaviorInclusive)),
61+
Recurring: &stripe.PriceRecurringParams{
62+
Interval: stripe.String(string(stripe.PriceRecurringIntervalMonth)),
63+
UsageType: stripe.String(string(stripe.PriceRecurringUsageTypeMetered)),
64+
AggregateUsage: stripe.String(string(stripe.PriceRecurringAggregateUsageSum)),
65+
},
66+
BillingScheme: stripe.String(string(stripe.PlanBillingSchemeTiered)),
67+
TiersMode: stripe.String(string(stripe.PlanTiersModeGraduated)),
68+
Tiers: []*stripe.PriceTierParams{
69+
{
70+
UnitAmount: stripe.Int64(perUnitPrice),
71+
UpToInf: stripe.Bool(true),
72+
},
73+
},
74+
ProductData: &stripe.PriceProductDataParams{
75+
Name: stripe.String(displayName),
76+
Metadata: map[string]string{
77+
"price_type": "usage",
78+
"subscription_item_type": itemType,
79+
"sms_region": smsRegion,
80+
"usage_type": "sms",
81+
"version": "2025",
82+
},
83+
},
84+
}
85+
result, err := api.Prices.New(params)
86+
if err != nil {
87+
return err
88+
}
89+
90+
updateProductParams := &stripe.ProductParams{
91+
DefaultPrice: stripe.String(result.ID),
92+
}
93+
_, err = api.Products.Update(result.Product.ID, updateProductParams)
94+
if err != nil {
95+
return err
96+
}
97+
98+
logger.
99+
WithField("name", displayName).
100+
WithField("product_id", result.Product.ID).
101+
Info("Created product")
102+
return nil
103+
}
104+
105+
func createWhatsappProduct(api *client.API, logger Logger, displayName, itemType string, whatsappRegion string, perUnitPrice int64) error {
106+
params := &stripe.PriceParams{
107+
Currency: stripe.String(string(stripe.CurrencyUSD)),
108+
TaxBehavior: stripe.String(string(stripe.PriceTaxBehaviorInclusive)),
109+
Recurring: &stripe.PriceRecurringParams{
110+
Interval: stripe.String(string(stripe.PriceRecurringIntervalMonth)),
111+
UsageType: stripe.String(string(stripe.PriceRecurringUsageTypeMetered)),
112+
AggregateUsage: stripe.String(string(stripe.PriceRecurringAggregateUsageSum)),
113+
},
114+
BillingScheme: stripe.String(string(stripe.PlanBillingSchemeTiered)),
115+
TiersMode: stripe.String(string(stripe.PlanTiersModeGraduated)),
116+
Tiers: []*stripe.PriceTierParams{
117+
{
118+
UnitAmount: stripe.Int64(perUnitPrice),
119+
UpToInf: stripe.Bool(true),
120+
},
121+
},
122+
ProductData: &stripe.PriceProductDataParams{
123+
Name: stripe.String(displayName),
124+
Metadata: map[string]string{
125+
"price_type": "usage",
126+
"subscription_item_type": itemType,
127+
"whatsapp_region": whatsappRegion,
128+
"usage_type": "whatsapp",
129+
"version": "2025",
130+
},
131+
},
132+
}
133+
result, err := api.Prices.New(params)
134+
if err != nil {
135+
return err
136+
}
137+
138+
updateProductParams := &stripe.ProductParams{
139+
DefaultPrice: stripe.String(result.ID),
140+
}
141+
_, err = api.Products.Update(result.Product.ID, updateProductParams)
142+
if err != nil {
143+
return err
144+
}
145+
146+
logger.
147+
WithField("name", displayName).
148+
WithField("product_id", result.Product.ID).
149+
Info("Created product")
150+
return nil
151+
}
152+
153+
func createMAUProduct(api *client.API, logger Logger, displayName, planName string, perGroupPrice, groupUnit, freeQuantity int64) error {
154+
params := &stripe.PriceParams{
155+
Params: stripe.Params{
156+
Metadata: map[string]string{
157+
"free_quantity": fmt.Sprint(freeQuantity),
158+
},
159+
},
160+
Currency: stripe.String(string(stripe.CurrencyUSD)),
161+
TaxBehavior: stripe.String(string(stripe.PriceTaxBehaviorInclusive)),
162+
Recurring: &stripe.PriceRecurringParams{
163+
Interval: stripe.String(string(stripe.PriceRecurringIntervalMonth)),
164+
UsageType: stripe.String(string(stripe.PriceRecurringUsageTypeMetered)),
165+
AggregateUsage: stripe.String(string(stripe.PriceRecurringAggregateUsageLastDuringPeriod)),
166+
},
167+
BillingScheme: stripe.String(string(stripe.PlanBillingSchemePerUnit)),
168+
UnitAmount: stripe.Int64(perGroupPrice),
169+
TransformQuantity: &stripe.PriceTransformQuantityParams{
170+
DivideBy: &groupUnit,
171+
Round: stripe.String(string(stripe.PriceTransformQuantityRoundUp)),
172+
},
173+
ProductData: &stripe.PriceProductDataParams{
174+
Name: stripe.String(displayName),
175+
Metadata: map[string]string{
176+
"plan_name": planName,
177+
"price_type": "usage",
178+
"subscription_item_type": "mau",
179+
"usage_type": "mau",
180+
"version": "2025",
181+
},
182+
},
183+
}
184+
result, err := api.Prices.New(params)
185+
if err != nil {
186+
return err
187+
}
188+
189+
updateProductParams := &stripe.ProductParams{
190+
DefaultPrice: stripe.String(result.ID),
191+
}
192+
_, err = api.Products.Update(result.Product.ID, updateProductParams)
193+
if err != nil {
194+
return err
195+
}
196+
197+
logger.
198+
WithField("name", displayName).
199+
WithField("product_id", result.Product.ID).
200+
Info("Created product")
201+
return nil
202+
}
203+
204+
var cmdPricingCreateStripePlans2025 = &cobra.Command{
205+
Use: "create-stripe-plans-2025",
206+
RunE: cobrasentry.RunEWrap(portalcmd.GetBinder, func(ctx context.Context, cmd *cobra.Command, args []string) (err error) {
207+
binder := portalcmd.GetBinder()
208+
209+
stripeSecretKey, err := binder.GetRequiredString(cmd, portalcmd.ArgStripeSecretKey)
210+
if err != nil {
211+
return
212+
}
213+
214+
stripeConfig := &portalconfig.StripeConfig{
215+
SecretKey: stripeSecretKey,
216+
}
217+
218+
hub := cobrasentry.GetHub(ctx)
219+
factory := cobrasentry.NewLoggerFactory(hub)
220+
logger := NewLogger(factory)
221+
222+
api := NewClientAPI(stripeConfig, logger)
223+
224+
err = createPlanProduct(api, logger,
225+
"developers2025", "Developers (2025)",
226+
50*Dollar,
227+
)
228+
if err != nil {
229+
return err
230+
}
231+
232+
err = createPlanProduct(api, logger,
233+
"business2025", "Business (2025)",
234+
500*Dollar,
235+
)
236+
if err != nil {
237+
return err
238+
}
239+
240+
err = createSMSProduct(api, logger,
241+
"SMS usage (North America) (2025)",
242+
"sms-north-america",
243+
"north-america",
244+
2*Cent,
245+
)
246+
if err != nil {
247+
return err
248+
}
249+
250+
err = createSMSProduct(api, logger,
251+
"SMS usage (Other regions) (2025)",
252+
"sms-other-region",
253+
"other-regions",
254+
10*Cent,
255+
)
256+
if err != nil {
257+
return err
258+
}
259+
260+
err = createWhatsappProduct(api, logger,
261+
"Whatsapp Usage (North America) (2025)",
262+
"whatsapp-north-america",
263+
"north-america",
264+
2*Cent,
265+
)
266+
if err != nil {
267+
return err
268+
}
269+
270+
err = createWhatsappProduct(api, logger,
271+
"Whatsapp Usage (Other regions) (2025)",
272+
"whatsapp-other-region",
273+
"other-regions",
274+
10*Cent,
275+
)
276+
if err != nil {
277+
return err
278+
}
279+
280+
err = createMAUProduct(api, logger,
281+
"Business Plan Additional MAU (2025)",
282+
"business2025",
283+
50*Dollar, 5000,
284+
25000,
285+
)
286+
if err != nil {
287+
return err
288+
}
289+
290+
return
291+
}),
292+
}

cmd/portal/cmd/cmdpricing/pricing.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ func init() {
5555
binder.BindString(cmdPricingAppUpdate.Flags(), portalcmd.ArgFeatureConfigFilePath)
5656
binder.BindString(cmdPricingAppUpdate.Flags(), portalcmd.ArgPlanNameForAppUpdate)
5757

58+
cmdPricing.AddCommand(cmdPricingCreateStripePlans2025)
59+
binder.BindString(cmdPricingCreateStripePlans2025.Flags(), portalcmd.ArgStripeSecretKey)
60+
5861
portalcmd.Root.AddCommand(cmdPricing)
5962
}
6063

pkg/lib/config/configsource/resources.go

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,10 @@ func (d AuthgearYAMLDescriptor) validateFeatureConfig(validationCtx *validation.
220220
if incomingFCError == nil || !ok {
221221
return incomingFCError
222222
}
223+
// https://github.com/authgear/authgear-server/commit/888e57b4b6fa9de7cd5786111cdc5cc244a85ac0
224+
// If the original config has some feature config error, we allow the user
225+
// to save the config without correcting them. This is for the case that
226+
// the app is downgraded from a higher plan.
223227
originalFCError := d.validateBasedOnFeatureConfig(original, fc)
224228
originalAggregatedError, ok := originalFCError.(*validation.AggregatedError)
225229
if originalFCError == nil || !ok {
@@ -519,6 +523,11 @@ func (d AuthgearSecretYAMLDescriptor) UpdateResource(ctx context.Context, _ []re
519523
return nil, fmt.Errorf("cannot delete '%v'", AuthgearSecretYAML)
520524
}
521525

526+
fc, ok := ctx.Value(ContextKeyFeatureConfig).(*config.FeatureConfig)
527+
if !ok || fc == nil {
528+
return nil, fmt.Errorf("missing feature config in context")
529+
}
530+
522531
var original *config.SecretConfig
523532
original, err := config.ParseSecret(resrc.Data)
524533
if err != nil {
@@ -548,12 +557,17 @@ func (d AuthgearSecretYAMLDescriptor) UpdateResource(ctx context.Context, _ []re
548557
return config.GenerateSAMLIdpSigningCertificate(commonName)
549558
},
550559
}
551-
updatedConfig, err := updateInstruction.ApplyTo(updateInstructionContext, original)
560+
incoming, err := updateInstruction.ApplyTo(updateInstructionContext, original)
552561
if err != nil {
553562
return nil, err
554563
}
555564

556-
updatedYAML, err := yaml.Marshal(updatedConfig)
565+
err = d.validate(ctx, original, incoming, fc)
566+
if err != nil {
567+
return nil, err
568+
}
569+
570+
updatedYAML, err := yaml.Marshal(incoming)
557571
if err != nil {
558572
return nil, err
559573
}
@@ -563,6 +577,46 @@ func (d AuthgearSecretYAMLDescriptor) UpdateResource(ctx context.Context, _ []re
563577
return &newResrc, nil
564578
}
565579

580+
func (d AuthgearSecretYAMLDescriptor) validate(ctx context.Context, original *config.SecretConfig, incoming *config.SecretConfig, fc *config.FeatureConfig) error {
581+
validationCtx := &validation.Context{}
582+
583+
featureConfigErr := func() error {
584+
incomingFCError := d.validateBasedOnFeatureConfig(incoming, fc)
585+
incomingAggregatedError, ok := incomingFCError.(*validation.AggregatedError)
586+
if incomingFCError == nil || !ok {
587+
return incomingFCError
588+
}
589+
// https://github.com/authgear/authgear-server/commit/888e57b4b6fa9de7cd5786111cdc5cc244a85ac0
590+
// If the original config has some feature config error, we allow the user
591+
// to save the config without correcting them. This is for the case that
592+
// the app is downgraded from a higher plan.
593+
originalFCError := d.validateBasedOnFeatureConfig(original, fc)
594+
originalAggregatedError, ok := originalFCError.(*validation.AggregatedError)
595+
if originalFCError == nil || !ok {
596+
return incomingFCError
597+
}
598+
599+
aggregatedError := incomingAggregatedError.Subtract(originalAggregatedError)
600+
return aggregatedError
601+
}()
602+
603+
validationCtx.AddError(featureConfigErr)
604+
605+
return validationCtx.Error(fmt.Sprintf("invalid %v", AuthgearSecretYAML))
606+
}
607+
608+
func (d AuthgearSecretYAMLDescriptor) validateBasedOnFeatureConfig(secretConfig *config.SecretConfig, fc *config.FeatureConfig) error {
609+
validationCtx := &validation.Context{}
610+
611+
if fc.Messaging.CustomSMTPDisabled {
612+
if _, _, ok := secretConfig.Lookup(config.SMTPServerCredentialsKey); ok {
613+
validationCtx.EmitErrorMessage("custom smtp is not allowed")
614+
}
615+
}
616+
617+
return validationCtx.Error("features are limited by feature config")
618+
}
619+
566620
var SecretConfig = resource.RegisterResource(AuthgearSecretYAMLDescriptor{})
567621

568622
type AuthgearFeatureYAMLDescriptor struct{}

0 commit comments

Comments
 (0)