Repository navigation
Expand file tree
/
Copy pathsubscription_write.go
More file actions
280 lines (260 loc) · 10.3 KB
/
Copy pathsubscription_write.go
File metadata and controls
280 lines (260 loc) · 10.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
package ledger
import (
"context"
"fmt"
"slices"
"time"
"github.com/xraph/ledger/id"
"github.com/xraph/ledger/invoice"
"github.com/xraph/ledger/plan"
"github.com/xraph/ledger/subscription"
)
// subscribablePlan loads a plan a subscription in appID may be on: it exists,
// it is in the same app, and it is active.
func (l *Ledger) subscribablePlan(ctx context.Context, planID id.PlanID, appID string) (*plan.Plan, error) {
if planID.IsNil() {
return nil, fmt.Errorf("%w: a subscription needs a plan", ErrInvalidInput)
}
p, err := l.store.GetPlan(ctx, planID)
if err != nil {
return nil, err
}
if p.AppID != appID {
return nil, fmt.Errorf("%w: the plan belongs to another app", ErrInvalidInput)
}
if p.Status != plan.StatusActive {
return nil, fmt.Errorf("%w: plan %q is %s, not active", ErrInvalidInput, p.Slug, p.Status)
}
return p, nil
}
// validateQuantity checks seat counts against the plan: non-negative, and only
// for the plan's seat features.
func validateQuantity(p *plan.Plan, quantity map[string]int64) error {
for key, n := range quantity {
if n < 0 {
return fmt.Errorf("%w: quantity for %q is negative", ErrInvalidQuantity, key)
}
f := p.FindFeature(key)
if f == nil || f.Type != plan.FeatureSeat {
return fmt.Errorf("%w: %q is not a seat feature of plan %q", ErrInvalidInput, key, p.Slug)
}
}
return nil
}
// ChangePlan moves a subscription to another active plan in the same app. The
// change takes effect from the next invoice; nothing is prorated. A nil
// quantity keeps the current seat counts.
func (l *Ledger) ChangePlan(ctx context.Context, subID id.SubscriptionID, planID id.PlanID, quantity map[string]int64) (*subscription.Subscription, error) {
sub, err := l.store.GetSubscription(ctx, subID)
if err != nil {
return nil, err
}
switch sub.Status {
case subscription.StatusCanceled:
return nil, ErrSubscriptionCanceled
case subscription.StatusExpired:
return nil, ErrSubscriptionExpired
}
next, err := l.subscribablePlan(ctx, planID, sub.AppID)
if err != nil {
return nil, err
}
if quantity == nil {
quantity = sub.Quantity
}
if err := validateQuantity(next, quantity); err != nil {
return nil, err
}
previous, err := l.store.GetPlan(ctx, sub.PlanID)
if err != nil {
return nil, err
}
// Only plan_id and quantity are written, and only while the subscription
// is neither canceled nor expired, so a period the lifecycle clock
// advanced or a cancel it enacted since the read above survives.
changed, err := l.store.ChangeSubscriptionPlan(ctx, subID, next.ID, quantity)
if err != nil {
return nil, err
}
sub, err = l.store.GetSubscription(ctx, subID)
if err != nil {
return nil, err
}
if !changed {
// The subscription stopped between the read and the write.
switch sub.Status {
case subscription.StatusExpired:
return nil, ErrSubscriptionExpired
default:
return nil, ErrSubscriptionCanceled
}
}
_ = l.store.Invalidate(ctx, sub.TenantID, sub.AppID) //nolint:errcheck // best-effort cache invalidation
l.plugins.EmitSubscriptionChanged(ctx, sub, previous, next)
return sub, nil
}
// PauseSubscription pauses an active or trialing subscription and records
// when, in paused_at. While it is paused the lifecycle clock leaves its
// period and its trial alone, so the billing cycle stands still until
// ResumeSubscription.
func (l *Ledger) PauseSubscription(ctx context.Context, subID id.SubscriptionID) (*subscription.Subscription, error) {
pause := func(ctx context.Context, subID id.SubscriptionID) (bool, error) {
return l.store.PauseSubscription(ctx, subID, l.stamp())
}
return l.transitionSubscription(ctx, subID, subscription.StatusPaused, pause,
subscription.StatusActive, subscription.StatusTrialing)
}
// resumeAttempts bounds how often ResumeSubscription starts again when a
// second pause lands between its read and its write.
const resumeAttempts = 3
// ResumeSubscription resumes a paused subscription. A subscription the
// lifecycle clock canceled while it was paused stays canceled.
//
// A pause stops the billing clock, so the resume stretches the period by the
// length of the pause: current_period_end moves on by now minus paused_at,
// and current_period_start stays. The stretched period carries one base fee
// and is billed once its new end passes, like any other; the lifecycle clock
// lists it in Renewal.Ended then, and the periods after it follow the
// clock's anchor from its new end, usually that end's day of the month. A
// cancel scheduled for the period end moves with it. Ledger remembers the
// most recent stretch (Subscription.Stretch), so ForPeriod can still prove
// the periods on both sides of it.
//
// A trial still running when the subscription was paused resumes as a trial,
// and its end moves on by the same length, so the customer gets the trial
// days they had left; the clock ends it, and fires OnSubscriptionTrialEnded,
// once that later date passes.
//
// A subscription paused before Ledger recorded paused_at has no pause start.
// Its period and its trial end stay where they were, and it resumes as a
// trial only if that end is still ahead.
//
// Everything is one conditional store write that lands only while the
// subscription is still paused and still carries the paused_at read here.
func (l *Ledger) ResumeSubscription(ctx context.Context, subID id.SubscriptionID) (*subscription.Subscription, error) {
for range resumeAttempts {
sub, err := l.store.GetSubscription(ctx, subID)
if err != nil {
return nil, err
}
if sub.Status != subscription.StatusPaused {
return nil, fmt.Errorf("%w: cannot move a %s subscription to %s", ErrInvalidInput, sub.Status, subscription.StatusActive)
}
changed, err := l.store.ResumeSubscription(ctx, subID, resumeOf(sub, l.stamp()))
if err != nil {
return nil, err
}
sub, err = l.store.GetSubscription(ctx, subID)
if err != nil {
return nil, err
}
if changed {
_ = l.store.Invalidate(ctx, sub.TenantID, sub.AppID) //nolint:errcheck // best-effort cache invalidation
return sub, nil
}
if sub.Status != subscription.StatusPaused {
return nil, fmt.Errorf("%w: cannot move a %s subscription to %s", ErrInvalidInput, sub.Status, subscription.StatusActive)
}
// Resumed and paused again since the read: start over from the new
// pause.
}
return nil, fmt.Errorf("%w: subscription %s was resumed and paused again while this resume was being worked out; try again", ErrInvalidInput, subID)
}
// resumeOf works out what resuming sub at now writes: the period end and a
// running trial's end moved on by the length of the pause, the stretch
// record, and trialing or active.
func resumeOf(sub *subscription.Subscription, now time.Time) subscription.Resume {
now = now.UTC()
r := subscription.Resume{PausedAt: sub.PausedAt, Status: subscription.StatusActive}
pauseStart := now // unknown for a pause from before paused_at existed
if sub.PausedAt != nil {
pauseStart = sub.PausedAt.UTC()
}
paused := now.Sub(pauseStart)
if paused > 0 {
start, end := sub.CurrentPeriodStart.UTC(), sub.CurrentPeriodEnd.UTC()
stretched := end.Add(paused)
r.PeriodEnd = &stretched
st := subscription.Stretch{Start: start, End: stretched, OriginalEnd: end}
if prev := sub.Stretch; prev != nil {
if prev.Start.Equal(start) {
// A second pause in the same period: the cadence before it
// still led to the first original end.
st.OriginalEnd, st.Floor = prev.OriginalEnd, prev.Floor
} else {
// The cadence this period sits on began at the previous
// stretch's end, and nothing before that is remembered.
floor := prev.End
st.Floor = &floor
}
}
r.Stretch = &st
}
if sub.TrialEnd == nil || !sub.TrialEnd.After(pauseStart) {
return r // no trial, or it had ended before the pause
}
r.Status = subscription.StatusTrialing
if paused > 0 {
trialEnd := sub.TrialEnd.UTC().Add(paused)
r.TrialEnd = &trialEnd
}
return r
}
// transitionSubscription moves a subscription whose status is one of from to
// the status to, through write, a conditional store write that repeats the
// same precondition and changes the status column alone. A whole-row write
// of the row read here could revive a subscription the lifecycle clock
// canceled after the read, or put back a period it advanced. When the write
// matches nothing, the status changed between the read and the write, and
// the refusal names the status it changed to.
func (l *Ledger) transitionSubscription(ctx context.Context, subID id.SubscriptionID, to subscription.Status,
write func(context.Context, id.SubscriptionID) (bool, error), from ...subscription.Status,
) (*subscription.Subscription, error) {
sub, err := l.store.GetSubscription(ctx, subID)
if err != nil {
return nil, err
}
if !slices.Contains(from, sub.Status) {
return nil, fmt.Errorf("%w: cannot move a %s subscription to %s", ErrInvalidInput, sub.Status, to)
}
changed, err := write(ctx, subID)
if err != nil {
return nil, err
}
sub, err = l.store.GetSubscription(ctx, subID)
if err != nil {
return nil, err
}
if !changed {
return nil, fmt.Errorf("%w: cannot move a %s subscription to %s", ErrInvalidInput, sub.Status, to)
}
_ = l.store.Invalidate(ctx, sub.TenantID, sub.AppID) //nolint:errcheck // best-effort cache invalidation
return sub, nil
}
// liveInvoiceForPeriod returns a non-voided invoice already covering the
// subscription's current period, or nil when there is none. It matches on the
// subscription as well as the period: two subscriptions for one tenant can
// share a period (SDK callers can align every period to the calendar), and
// each of them is billed on its own.
//
// It lists the period rather than calling GetInvoiceByPeriod: that method
// returns a single row, and once an invoice has been voided and regenerated
// two rows share the period, so it could hand back the voided one and let a
// third invoice through.
func (l *Ledger) liveInvoiceForPeriod(ctx context.Context, sub *subscription.Subscription) (*invoice.Invoice, error) {
invs, err := l.store.ListInvoices(ctx, sub.TenantID, sub.AppID, invoice.ListOpts{
Start: sub.CurrentPeriodStart,
End: sub.CurrentPeriodEnd,
})
if err != nil {
return nil, err
}
for _, inv := range invs {
if inv.SubscriptionID == sub.ID && inv.TenantID == sub.TenantID && inv.AppID == sub.AppID &&
inv.PeriodStart.Equal(sub.CurrentPeriodStart) && inv.PeriodEnd.Equal(sub.CurrentPeriodEnd) &&
inv.Status != invoice.StatusVoided {
return inv, nil
}
}
return nil, nil
}